Skip to content

Commit 5d4b540

Browse files
committed
Say which Slack link conflict happened, and stop caching the claim
Four things from review, and a dependency narrowing that was asked for. The 409 from linking a Slack account said one sentence for two opposite conflicts. The store already knew which key the insert lost to: the Slack identity belonging to another OpenBot account, or the caller's own account already linked to a different Slack user in the same workspace. It threw the same string for both, so somebody re-linking under a new Slack id was told their identity belonged to another account -- a false claim about their own account, with no action attached. The conflict now travels as a code, and the confirmation page says the true one. GET and POST on the link route did not send `Cache-Control: no-store`, which every sibling route in the file does. The request URL carries the token and the response is the identity claim decoded from it, so an intermediary keying on that URL would hold a decoded claim beside the credential that produced it. The read-only Slack transcript had no rejection handler: a failed `/messages` left the view on its restoring skeleton for as long as somebody left it open, and rejected with nobody listening. The `unreadable` counter it should have fed was unreachable -- the read is all-or-nothing -- so it is a fact about the read now, and says the conversation could not be read. A Slack turn established its private execution context twice, and protecting copied every time, so a turn had two executions: the run wrote `agentId` to one and a computer tool reading the other would have found none and refused. It only worked because someone else's agent loop happens to invoke tool handlers after the run returns. Protecting an already-protected execution now returns it unchanged, which holds the invariant here rather than in a dependency, and there is a test for it. The stable-threadId property the append-only binding rests on is named at the binding site, because it is a property of managed delivery rather than of Channels. `@copilotkit/channels` was the umbrella package, so the Discord, Telegram, Teams and WhatsApp adapters came with it to be used by nothing. Narrowed to `channels-core` and `channels-ui`, with `channels-slack` a devDependency for the one test that asserts rendered Block Kit. `waitForAssistance` was replaced by `waitForExactAssistance` and called by nothing while keeping ninety lines of tests, and `pinnedFirst` was superseded by `conversationRoster`. Both removed, and what their tests uniquely covered -- the bounded wait expiring after the link is posted, a turn cancelled mid-wait, and a title never moving a row -- is now asserted on the paths that ship.
1 parent d2b5c60 commit 5d4b540

32 files changed

Lines changed: 485 additions & 389 deletions

app/src/components/app-sidebar/app-sidebar.tsx

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -106,20 +106,6 @@ function UserAvatar() {
106106
*/
107107
const MAX_ANIMATED_ROWS = 60;
108108

109-
/**
110-
* Pinned channels first, everything else after, newest activity first within each group.
111-
*
112-
* The mirror of a server rule, not the rule itself: the roster query orders pinned-first and its
113-
* cursor carries the pin, so a pinned channel arrives on page one however long ago it was last
114-
* spoken in. Sorting here as well is for what happens between refetches — the socket patches a pin
115-
* onto a loaded row without moving it, and re-sorts a page by recency alone — which is the same
116-
* reason `byRecency` in use-channel-events.ts mirrors the recency rule. A stable partition, so the
117-
* recency order inside each group is whatever arrived.
118-
*/
119-
export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] {
120-
return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned));
121-
}
122-
123109
/**
124110
* Whether a Bot has said something this member has not had on screen yet.
125111
*

app/src/components/channels/external-thread-chat.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,33 @@ export function ExternalThreadChat({
1313
}) {
1414
const [messages, setMessages] = useState<readonly Message[]>([]);
1515
const [restoring, setRestoring] = useState(true);
16-
const [unreadable, setUnreadable] = useState(0);
16+
const [unreadable, setUnreadable] = useState(false);
1717

1818
useEffect(() => {
1919
let current = true;
20-
void readExternalThreadMessages(target.threadId).then((stored) => {
21-
if (!current) return;
22-
setMessages(stored);
23-
setUnreadable(0);
24-
setRestoring(false);
25-
});
20+
setRestoring(true);
21+
setUnreadable(false);
22+
void readExternalThreadMessages(target.threadId)
23+
.then((stored) => {
24+
if (!current) return;
25+
setMessages(stored);
26+
setRestoring(false);
27+
})
28+
/*
29+
* A read that fails has to stop the restoring state and say so.
30+
*
31+
* Without this the promise rejects with nobody listening and the view sits on its skeleton
32+
* for as long as the person leaves it open — which reads as a conversation still loading
33+
* rather than one that could not be read, and is the state a failed `/messages` used to leave
34+
* behind. The transcript is all-or-nothing: the endpoint either answers with the turns or it
35+
* does not, so this is a fact about the read and not a count of messages.
36+
*/
37+
.catch(() => {
38+
if (!current) return;
39+
setMessages([]);
40+
setUnreadable(true);
41+
setRestoring(false);
42+
});
2643
return () => {
2744
current = false;
2845
};
@@ -38,11 +55,10 @@ export function ExternalThreadChat({
3855
This is the canonical Slack conversation with {target.agentName}. It
3956
is read-only here for this demo; continue the conversation in Slack.
4057
</p>
41-
{unreadable > 0 ? (
58+
{unreadable ? (
4259
<p>
43-
{unreadable === 1
44-
? "One earlier message could not be read."
45-
: `${unreadable} earlier messages could not be read.`}
60+
This conversation could not be read. It is still in Slack; reload
61+
to try again.
4662
</p>
4763
) : null}
4864
</div>

app/src/lib/channels/use-channel-events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export function applyChannelEvent(
152152
*
153153
* The spread below would carry this event's null message onto the row and wipe the preview the
154154
* roster renders. No re-sort either: a pin is not activity, and pinned rows are lifted at render
155-
* time by `pinnedFirst`, not by the order they sit in here.
155+
* time by `conversationRoster`, not by the order they sit in here.
156156
*/
157157
if (activity.pinned !== undefined) {
158158
if (previous.pinned === activity.pinned) return data;

app/src/routes/_authed/link/slack.tsx

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,39 @@ export function slackLinkToken(search: Record<string, unknown>): string | null {
4141
return token === "" ? null : token;
4242
}
4343

44-
export function slackLinkResult(status: number) {
44+
/**
45+
* Which key the link lost to, as the server reports it.
46+
*
47+
* Only one of the two is about somebody else's account, and saying the wrong one is worse than
48+
* saying nothing: a person re-linking under a new Slack id in the same workspace was told their
49+
* identity belonged to another OpenBot account, which is a false claim about their own and one
50+
* they cannot act on. An unrecognised or absent code falls back to the safe half of the pair.
51+
*/
52+
export type SlackLinkConflict =
53+
| "provider_identity_linked"
54+
| "openbot_user_linked";
55+
56+
export function slackLinkConflict(value: unknown): SlackLinkConflict {
57+
const conflict =
58+
value && typeof value === "object" && !Array.isArray(value)
59+
? (value as { conflict?: unknown }).conflict
60+
: undefined;
61+
return conflict === "openbot_user_linked"
62+
? "openbot_user_linked"
63+
: "provider_identity_linked";
64+
}
65+
66+
const SLACK_LINK_CONFLICT_MESSAGES = {
67+
provider_identity_linked:
68+
"That Slack identity is already linked to another OpenBot account.",
69+
openbot_user_linked:
70+
"Your OpenBot account is already linked to a different Slack user in this workspace. Unlink it before linking this one.",
71+
} as const satisfies Record<SlackLinkConflict, string>;
72+
73+
export function slackLinkResult(
74+
status: number,
75+
conflict: SlackLinkConflict = "provider_identity_linked",
76+
) {
4577
if (status === 200)
4678
return {
4779
kind: "linked",
@@ -50,8 +82,7 @@ export function slackLinkResult(status: number) {
5082
if (status === 409)
5183
return {
5284
kind: "conflict",
53-
message:
54-
"That Slack identity is already linked to another OpenBot account.",
85+
message: SLACK_LINK_CONFLICT_MESSAGES[conflict],
5586
} as const;
5687
return {
5788
kind: "invalid",
@@ -68,10 +99,13 @@ export function slackLinkFailure(): SlackLinkFailure {
6899
}
69100

70101
/** Only documented token refusals are terminal-invalid; unknown responses stay retryable. */
71-
export function slackLinkResponseOutcome(status?: number): SlackLinkResponse {
102+
export function slackLinkResponseOutcome(
103+
status?: number,
104+
conflict?: SlackLinkConflict,
105+
): SlackLinkResponse {
72106
if (status === 401) return { kind: "reauth" };
73107
if (status === 200 || status === 400 || status === 409)
74-
return slackLinkResult(status);
108+
return slackLinkResult(status, conflict);
75109
return slackLinkFailure();
76110
}
77111

@@ -126,7 +160,12 @@ async function completeSlackLink(
126160
body: { token },
127161
signal,
128162
});
129-
return slackLinkResponseOutcome(response.status);
163+
// Read only on the conflict: it is the one status whose body decides what the page may say.
164+
const conflict =
165+
response.status === 409
166+
? slackLinkConflict(await response.json().catch(() => null))
167+
: undefined;
168+
return slackLinkResponseOutcome(response.status, conflict);
130169
}
131170

132171
function SlackLinkPage() {

app/tests/channel-order.test.ts

Lines changed: 0 additions & 77 deletions
This file was deleted.

app/tests/sidebar-roster.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function channel(
2222
agentIds: [`agent-${id}`],
2323
threadId: `thread-${id}`,
2424
active: true,
25+
summary: null,
2526
lastMessage: null,
2627
lastMessageAt: null,
2728
lastMessageAgentId: null,
@@ -88,6 +89,29 @@ describe("sidebar conversation roster", () => {
8889
]);
8990
});
9091

92+
/**
93+
* Naming a conversation is not activity in it.
94+
*
95+
* Whatever order the roster was in, it is the same order once titles arrive, or rows would appear
96+
* to jump for no reason anybody looking at them could account for. Held by construction — the
97+
* sort reads activity and the row key, never the summary — and asserted because that is the kind
98+
* of thing a later sort change breaks quietly.
99+
*/
100+
test("a title changes nothing about where a row sits", () => {
101+
const ids = (rows: ReturnType<typeof conversationRoster>) =>
102+
rows.map((row) => rosterKey(row));
103+
const untitled = [channel("a"), channel("b", { pinned: true })];
104+
const titled = [
105+
channel("a", { summary: "Expense categories" }),
106+
channel("b", { pinned: true, summary: "Quarterly revenue" }),
107+
];
108+
const threads = [slack("s1")];
109+
110+
expect(ids(conversationRoster(titled, threads))).toEqual(
111+
ids(conversationRoster(untitled, threads)),
112+
);
113+
});
114+
91115
test("matches visible names and last-message text across native and Slack rows", () => {
92116
const rows = conversationRoster(
93117
[

app/tests/slack-link-route.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, test } from "bun:test";
22
import {
33
slackLinkClaim,
4+
slackLinkConflict,
45
slackLinkFailure,
56
slackLinkResponseOutcome,
67
slackLinkResult,
@@ -17,6 +18,43 @@ test("requires a token and maps completion responses", () => {
1718
expect(slackLinkResult(409).kind).toBe("conflict");
1819
});
1920

21+
/**
22+
* The two 409s say opposite things, and only one of them is about somebody else.
23+
*
24+
* A person re-linking under a new Slack id in the same workspace used to be told their identity
25+
* belonged to another OpenBot account: a false claim about their own account, and one with no
26+
* action attached. The server distinguishes the two keys, so this page has to as well — and an
27+
* unrecognised or absent code has to fall back to the claim that is safe to make.
28+
*/
29+
test("says which of the two conflicts happened, and falls back to the safe one", () => {
30+
expect(slackLinkResult(409, "provider_identity_linked").message).toBe(
31+
"That Slack identity is already linked to another OpenBot account.",
32+
);
33+
expect(slackLinkResult(409, "openbot_user_linked").message).toBe(
34+
"Your OpenBot account is already linked to a different Slack user in this workspace. Unlink it before linking this one.",
35+
);
36+
expect(slackLinkResult(409).message).toBe(
37+
slackLinkResult(409, "provider_identity_linked").message,
38+
);
39+
40+
expect(slackLinkConflict({ conflict: "openbot_user_linked" })).toBe(
41+
"openbot_user_linked",
42+
);
43+
expect(slackLinkConflict({ conflict: "provider_identity_linked" })).toBe(
44+
"provider_identity_linked",
45+
);
46+
for (const body of [
47+
null,
48+
undefined,
49+
{},
50+
{ conflict: 42 },
51+
{ conflict: "something_else" },
52+
["openbot_user_linked"],
53+
]) {
54+
expect(slackLinkConflict(body)).toBe("provider_identity_linked");
55+
}
56+
});
57+
2058
test("rejects non-string, empty, and repeated token search inputs", () => {
2159
expect(slackLinkToken({ token: "" })).toBeNull();
2260
expect(slackLinkToken({ token: " " })).toBeNull();
@@ -46,6 +84,9 @@ test("classifies documented token, authentication, and transient responses", ()
4684
expect(slackLinkResponseOutcome(status).kind).toBe("invalid");
4785
}
4886
expect(slackLinkResponseOutcome(409).kind).toBe("conflict");
87+
expect(
88+
slackLinkResponseOutcome(409, "openbot_user_linked").message,
89+
).toContain("a different Slack user in this workspace");
4990
expect(slackLinkResponseOutcome(401).kind).toBe("reauth");
5091

5192
for (const status of [408, 418, 425, 429, 500, 502, 503]) {

0 commit comments

Comments
 (0)