mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
feat(web): agent roster on /messages with Start chat, and a sign-out redirect (TASK-4044) (#2324)
* feat(web): ensureConversation client call, carried from the chat window work (TASK-4044) Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * feat(web): chat roster, ensureDM and a reference-counted presence poll (TASK-4044) Ruling 018faf64: counter clamped at 0, DM keys filtered to kind dm, ensure fenced by identity. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * feat(web): roster rows with Start chat on /messages, and a sign-out redirect (TASK-4044) Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * test(web): census entry for the roster each (TASK-4044) Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * fix(web): roster keeps DMs by the server's 'direct' kind; presence owners are balanced (TASK-4044) The roster filtered on kind 'dm', which the server never writes (migration 158 allows 'direct' and 'group'), so against a real server every conversation dropped off the roster. Fixtures are corrected to 'direct', the Q3 row now uses a group conversation, and an e2e spec reads the server's real value. The chatInbox afterEach asserts no presence timer is left armed before one safety drain, so a leaked owner fails. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * fix(web): roster ensure does not navigate after the page is left (TASK-4044) Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
This commit is contained in:
+1
-1
@@ -259,7 +259,7 @@ A human's `agent_key` on `POST /me/conversations` must already be a normalised a
|
||||
|
||||
**Provenance caveat (TASK-4039 / TASK-4041):** `X-Pad-Agent` is self-declared attribution, not authentication. A holder of the owner's credential can choose another agent label within that owner's namespace, or omit it and post as the human, also avoiding the agent send bucket (the general API limit still applies). An owner-authored message is therefore **not authenticated as human input**. Stage 2a accepts this because both conversation members represent the same owner and credential authority; it must not be presented as a verified human channel. Owner-authored messages are framed as information by default (TASK-4041); `pad chat arm --accept-instructions` opts this session into an unauthenticated channel controlled by the agent's own credential holder. The framing rules are under `GET /api/v1/events/stream` above. A verifiable human channel would require a credential the agent cannot hold.
|
||||
|
||||
Wire types are explicit projections; `owner_user_id`, `dm_key`, `author_user_id`, raw `context_workspace_id` and `op_key` never appear in responses. `Conv` is `{id, number, kind, title, agent_key, last_seq, created_at, updated_at}`. `ConvDetail` adds `{read_seq, unread}`. The single-conversation GET additionally includes the caller's own `delivered_seq`; inbox rows omit it. `ConvSummary` adds optional `last_message: {seq, author_agent_key, author_is_human, excerpt, deleted, created_at}` to the detail; it never includes a full body. `Msg` is `{id, conversation_id, seq, author_agent_key, author_is_human, mine, source, body, reply_to_seq?, reply_to?, context_workspace?: {slug, name}, created_at, deleted, deleted_at?}`; `mine` compares both components of the author and actor pair. `reply_to` is `{seq, author_agent_key, author_is_human, excerpt, deleted}`, the store's bounded quote preview.
|
||||
Wire types are explicit projections; `owner_user_id`, `dm_key`, `author_user_id`, raw `context_workspace_id` and `op_key` never appear in responses. `Conv` is `{id, number, kind, title, agent_key, last_seq, created_at, updated_at}`. `kind` is `"direct"` or `"group"`; stage 2a creates only `"direct"`. `ConvDetail` adds `{read_seq, unread}`. The single-conversation GET additionally includes the caller's own `delivered_seq`; inbox rows omit it. `ConvSummary` adds optional `last_message: {seq, author_agent_key, author_is_human, excerpt, deleted, created_at}` to the detail; it never includes a full body. `Msg` is `{id, conversation_id, seq, author_agent_key, author_is_human, mine, source, body, reply_to_seq?, reply_to?, context_workspace?: {slug, name}, created_at, deleted, deleted_at?}`; `mine` compares both components of the author and actor pair. `reply_to` is `{seq, author_agent_key, author_is_human, excerpt, deleted}`, the store's bounded quote preview.
|
||||
|
||||
Paths in the following table omit `/api/v1`. `{n}` is the owner's positive conversation number; `{seq}` is a positive message sequence. Every route also has the admission errors above and generic `500 internal_error` on unexpected failures.
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
/**
|
||||
* TASK-4044 (ruling 140d6e04): the /messages roster keeps a conversation as a
|
||||
* DM by its `kind`. The server writes 'direct' (migrations/158_conversations.sql
|
||||
* CHECK kind IN ('direct','group')); a roster filter on any other literal drops
|
||||
* every conversation, and unit fixtures cannot see that because they build their
|
||||
* own kind. This spec reads the server's real value: a DM created through the
|
||||
* API must show up in the roster as a link to /messages/<number>.
|
||||
*/
|
||||
test('the roster shows an agent with a DM as a link to its conversation', async ({
|
||||
page,
|
||||
request,
|
||||
fixture
|
||||
}) => {
|
||||
const agent = `rosterlink-${Date.now().toString(36)}`;
|
||||
const res = await request.post('/api/v1/me/conversations', {
|
||||
headers: { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' },
|
||||
data: { agent_key: agent }
|
||||
});
|
||||
expect(res.ok(), await res.text()).toBe(true);
|
||||
const { conversation } = (await res.json()) as { conversation: { number: number } };
|
||||
expect(conversation.number).toBeGreaterThan(0);
|
||||
|
||||
await page.goto('/messages');
|
||||
|
||||
const row = page
|
||||
.getByRole('list', { name: 'Agents' })
|
||||
.locator('a.row')
|
||||
.filter({ has: page.locator('.name', { hasText: agent }) });
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row).toHaveAttribute('href', `/messages/${conversation.number}`);
|
||||
});
|
||||
@@ -85,6 +85,7 @@ import type {
|
||||
AttachmentTransformResult,
|
||||
ServerCapabilities,
|
||||
LiveSessionsResponse,
|
||||
ChatConversation,
|
||||
ChatConversationSummary,
|
||||
ChatConversationsResponse,
|
||||
ChatUnreadResponse,
|
||||
@@ -2440,6 +2441,12 @@ export const api = {
|
||||
chat: {
|
||||
listConversations: () => request<ChatConversationsResponse>('/me/conversations'),
|
||||
|
||||
/** Create-or-reuse the DM with `agent_key`; 201 when created, 200 when reused. */
|
||||
ensureConversation: (agent_key: string) =>
|
||||
request<{ conversation: ChatConversation; created: boolean }>('/me/conversations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent_key })
|
||||
}),
|
||||
|
||||
getConversation: (n: number) =>
|
||||
request<ChatConversationSummary>(`/me/conversations/${n}`),
|
||||
|
||||
@@ -20,7 +20,7 @@ function conv(n: number, over: Partial<ChatConversationSummary> = {}): ChatConve
|
||||
return {
|
||||
id: `c${n}`,
|
||||
number: n,
|
||||
kind: 'dm',
|
||||
kind: 'direct',
|
||||
title: '',
|
||||
agent_key: `agent-${n}`,
|
||||
last_seq: 1,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import PresenceDot from '$lib/components/chat/PresenceDot.svelte';
|
||||
import UnreadBadge from '$lib/components/chat/UnreadBadge.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import type { RosterEntry } from '$lib/stores/chatInbox.svelte';
|
||||
|
||||
let {
|
||||
entry,
|
||||
checkedAt = null,
|
||||
pending = false,
|
||||
rateLimited = false,
|
||||
failed = false,
|
||||
onensure
|
||||
}: {
|
||||
entry: RosterEntry;
|
||||
checkedAt?: number | null;
|
||||
pending?: boolean;
|
||||
rateLimited?: boolean;
|
||||
failed?: boolean;
|
||||
onensure: (key: string) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if entry.number !== null}
|
||||
<a class="row" href="/messages/{entry.number}">
|
||||
<PresenceDot state={entry.presence} {checkedAt} />
|
||||
<span class="name">{entry.key}</span>
|
||||
<UnreadBadge count={chatStore.unreadByNumber[entry.number] ?? 0} />
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="row"
|
||||
disabled={pending || rateLimited}
|
||||
onclick={() => onensure(entry.key)}
|
||||
>
|
||||
<PresenceDot state={entry.presence} {checkedAt} />
|
||||
<span class="name">{entry.key}</span>
|
||||
<span class="action">Start chat</span>
|
||||
</button>
|
||||
{#if rateLimited}
|
||||
<p class="note" role="status">Too many new chats right now. Try again in a few seconds.</p>
|
||||
{:else if failed}
|
||||
<p class="note" role="status">Couldn't start the chat. Try again.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
.row:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,7 @@ export const EACH_KEY_REASONS = {
|
||||
export type EachKeyReason = keyof typeof EACH_KEY_REASONS;
|
||||
|
||||
export const EACH_KEY_CENSUS: Record<string, { reason: EachKeyReason; note: string }> = {
|
||||
"routes/messages/+page.svelte :: each chatInbox.roster as entry (entry.key)": { reason: 'object-or-map-keys', note: "chatInbox.roster is built by iterating the `presences` Map (rollupPresence), keyed by agent name | Map keys cannot repeat." },
|
||||
"lib/components/BacklinksPanel.svelte :: each group.rows as bl, blIdx (rowKey(bl, blIdx))": { reason: 'position-or-counter', note: "rowKey ends with the row's index" },
|
||||
"lib/components/BacklinksPanel.svelte :: each groups as group (group.key)": { reason: 'object-or-map-keys', note: "SvelteMap keyed by `ws::collection` (l.121) | Map keys." },
|
||||
"lib/components/ChildChart.svelte :: each chartData.yTicks as tick (tick.value)": { reason: 'strictly-increasing', note: "local loop (l.132-139) | `v` rises strictly by `step >= 1`; `total` is pushed only if it is not already last." },
|
||||
|
||||
@@ -8,11 +8,14 @@ const h = vi.hoisted(() => ({
|
||||
unread: vi.fn(),
|
||||
list: vi.fn(),
|
||||
sessions: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
state: { authenticated: true, epoch: 0, listeners: [] as Array<(prev: string) => void> }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: { chat: { unread: h.unread, listConversations: h.list }, sessions: { list: h.sessions } }
|
||||
api: {
|
||||
chat: { unread: h.unread, listConversations: h.list, ensureConversation: h.ensure },
|
||||
sessions: { list: h.sessions } }
|
||||
}));
|
||||
vi.mock('$lib/stores/auth.svelte', () => ({
|
||||
authStore: {
|
||||
@@ -31,7 +34,7 @@ vi.mock('$lib/stores/auth.svelte', () => ({
|
||||
}));
|
||||
|
||||
import { CHAT_HINT_DEBOUNCE_MS, CHAT_RETRY_MS } from './chat.svelte';
|
||||
import { PRESENCE_POLL_MS, PRESENCE_STALE_MS } from './chatInbox.svelte';
|
||||
import { ENSURE_COOLDOWN_MS, PRESENCE_POLL_MS, PRESENCE_STALE_MS } from './chatInbox.svelte';
|
||||
|
||||
type Chat = (typeof import('./chat.svelte'))['chatStore'];
|
||||
type Inbox = (typeof import('./chatInbox.svelte'))['chatInbox'];
|
||||
@@ -41,7 +44,7 @@ let inbox: Inbox;
|
||||
const conv = (n: number) => ({
|
||||
id: `c${n}`,
|
||||
number: n,
|
||||
kind: 'dm',
|
||||
kind: 'direct',
|
||||
title: '',
|
||||
agent_key: `a${n}`,
|
||||
last_seq: 1,
|
||||
@@ -87,6 +90,7 @@ beforeEach(async () => {
|
||||
h.unread.mockReset();
|
||||
h.list.mockReset();
|
||||
h.sessions.mockReset();
|
||||
h.ensure.mockReset();
|
||||
h.sessions.mockResolvedValue({ sessions: [], count: 0 });
|
||||
setVisibility('visible');
|
||||
h.unread.mockResolvedValue({ conversations: [], total: 0 });
|
||||
@@ -98,10 +102,21 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// The list and the unread store hold their own timers: end them first, so
|
||||
// the only timer left standing can be a presence poll.
|
||||
inbox.stop();
|
||||
inbox.stopPresence();
|
||||
chat.stop();
|
||||
vi.useRealTimers();
|
||||
try {
|
||||
// startPresence is reference counted. Every test releases the owners it took
|
||||
// (the describes that start presence do it in their own afterEach, which runs
|
||||
// before this one), so an armed presence poll here is a leaked owner.
|
||||
expect(vi.getTimerCount(), 'a test left a presence owner started').toBe(0);
|
||||
} finally {
|
||||
// One drain for safety only; the assertion above ran first. Its failure must
|
||||
// not leave the fake clock behind for the next test.
|
||||
inbox.stopPresence();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
describe('chatInbox (TASK-4044)', () => {
|
||||
@@ -265,6 +280,11 @@ describe('chatInbox (TASK-4044)', () => {
|
||||
});
|
||||
|
||||
describe('chatInbox presence (TASK-4044 2b-i)', () => {
|
||||
afterEach(() => {
|
||||
// These tests start one presence owner each and do not stop it themselves.
|
||||
inbox.stopPresence();
|
||||
});
|
||||
|
||||
it('startPresence fires no list fetch', async () => {
|
||||
inbox.startPresence();
|
||||
await flush();
|
||||
@@ -317,6 +337,8 @@ describe('chatInbox presence (TASK-4044 2b-i)', () => {
|
||||
expect(h.sessions).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(PRESENCE_POLL_MS);
|
||||
expect(h.sessions).toHaveBeenCalledTimes(2);
|
||||
// Two owners were taken; the describe's afterEach releases one.
|
||||
inbox.stopPresence();
|
||||
});
|
||||
|
||||
it('reads online from a matching session, offline only after a successful read without one', async () => {
|
||||
@@ -464,3 +486,228 @@ describe('chatInbox presence (TASK-4044 2b-i)', () => {
|
||||
expect(inbox.presences.get('a2')).toBe('offline');
|
||||
});
|
||||
});
|
||||
|
||||
// TASK-4044 2b-ii: the roster and ensureDM (ruling 018faf64).
|
||||
const ensureOk = (n: number, created = true) => ({ conversation: conv(n), created });
|
||||
const rateErr = (retryAfterMs?: number) =>
|
||||
Object.assign(new Error('rate_limited'), { code: 'rate_limited', retryAfterMs });
|
||||
const rosterKeys = () => inbox.roster.map((r) => r.key);
|
||||
|
||||
describe('chatInbox roster (TASK-4044 2b-ii)', () => {
|
||||
afterEach(() => {
|
||||
// These tests start one presence owner each and do not stop it themselves.
|
||||
inbox.stopPresence();
|
||||
});
|
||||
|
||||
async function loadWith(convs: ReturnType<typeof conv>[], sessions: ReturnType<typeof sess>[] | 'down') {
|
||||
h.list.mockResolvedValue({ conversations: convs, unread_total: 0 });
|
||||
if (sessions === 'down') h.sessions.mockRejectedValue(apiErr('unavailable'));
|
||||
else h.sessions.mockResolvedValue(sessResp(...sessions));
|
||||
inbox.start();
|
||||
inbox.startPresence();
|
||||
await flush();
|
||||
}
|
||||
|
||||
it('orders online, busy, connected-not-accepting, unknown, offline, ties by name', async () => {
|
||||
const named = (n: number, key: string) => ({ ...conv(n), agent_key: key });
|
||||
await loadWith(
|
||||
[named(1, 'zed'), named(2, 'amy'), named(3, 'bob'), named(4, 'cat'), named(5, 'dan'), named(6, 'eve')],
|
||||
[
|
||||
sess('zed', 'online'),
|
||||
sess('amy', 'online'),
|
||||
sess('bob', 'busy'),
|
||||
sess('cat', 'connected_not_accepting')
|
||||
]
|
||||
);
|
||||
expect(rosterKeys()).toEqual(['amy', 'zed', 'bob', 'cat', 'dan', 'eve']);
|
||||
expect(inbox.roster.map((r) => r.presence)).toEqual([
|
||||
'online',
|
||||
'online',
|
||||
'busy_uncertain',
|
||||
'connected_not_accepting',
|
||||
'offline',
|
||||
'offline'
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the DM number, and null for a session-only agent', async () => {
|
||||
await loadWith([conv(1)], [sess('ghost'), sess('a1')]);
|
||||
expect(inbox.roster).toEqual([
|
||||
{ key: 'a1', presence: 'online', number: 1 },
|
||||
{ key: 'ghost', presence: 'online', number: null }
|
||||
]);
|
||||
});
|
||||
|
||||
// Row 15: unread belongs to chatStore alone.
|
||||
it('carries no unread count', async () => {
|
||||
await loadWith([{ ...conv(1), unread: 4 }], []);
|
||||
for (const r of inbox.roster) expect(Object.keys(r).sort()).toEqual(['key', 'number', 'presence']);
|
||||
});
|
||||
|
||||
// Row 16 / Q-e: while sessions cannot be read, only DM agents are listed, as unknown.
|
||||
it('shows session-only agents after a good read and drops them when unavailable', async () => {
|
||||
await loadWith([conv(1)], [sess('ghost')]);
|
||||
expect(rosterKeys()).toContain('ghost');
|
||||
h.sessions.mockRejectedValue(apiErr('unavailable'));
|
||||
await vi.advanceTimersByTimeAsync(PRESENCE_POLL_MS);
|
||||
await flush();
|
||||
expect(rosterKeys()).toEqual(['a1']);
|
||||
expect(inbox.roster[0].presence).toBe('unknown');
|
||||
});
|
||||
|
||||
it('before the first read the roster is the DM agents as unknown', async () => {
|
||||
h.sessions.mockReturnValue(new Promise(() => {}));
|
||||
h.list.mockResolvedValue(listResp(1));
|
||||
inbox.start();
|
||||
inbox.startPresence();
|
||||
await flush();
|
||||
expect(inbox.roster).toEqual([{ key: 'a1', presence: 'unknown', number: 1 }]);
|
||||
});
|
||||
|
||||
// Q3
|
||||
it("a group conversation (kind 'group') is not on the roster", async () => {
|
||||
await loadWith([conv(1), { ...conv(2), kind: 'group', agent_key: 'notadm' }], []);
|
||||
expect(rosterKeys()).toEqual(['a1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatInbox ensureDM (TASK-4044 2b-ii)', () => {
|
||||
it('returns the number for a created (201) and a reused (200) DM, and reloads each time', async () => {
|
||||
h.ensure.mockResolvedValueOnce(ensureOk(7, true)).mockResolvedValueOnce(ensureOk(7, false));
|
||||
expect(await inbox.ensureDM('amy')).toBe(7);
|
||||
expect(await inbox.ensureDM('amy')).toBe(7);
|
||||
expect(h.ensure).toHaveBeenCalledTimes(2);
|
||||
expect(h.list).toHaveBeenCalledTimes(2);
|
||||
expect(inbox.ensuring.size).toBe(0);
|
||||
});
|
||||
|
||||
// Row 7
|
||||
it('a double click fires one POST', async () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
h.ensure.mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const first = inbox.ensureDM('amy');
|
||||
const second = await inbox.ensureDM('amy');
|
||||
expect(second).toBeNull();
|
||||
expect(h.ensure).toHaveBeenCalledTimes(1);
|
||||
resolve(ensureOk(7));
|
||||
expect(await first).toBe(7);
|
||||
});
|
||||
|
||||
it('an empty key posts nothing', async () => {
|
||||
expect(await inbox.ensureDM(' ')).toBeNull();
|
||||
expect(h.ensure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Row 8
|
||||
it('a 429 is one POST, never retried, and flagged', async () => {
|
||||
h.ensure.mockRejectedValue(rateErr());
|
||||
expect(await inbox.ensureDM('amy')).toBeNull();
|
||||
expect(inbox.rateLimited['amy']).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flush();
|
||||
expect(h.ensure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Row 9
|
||||
it.each([
|
||||
[undefined, ENSURE_COOLDOWN_MS],
|
||||
[1000, ENSURE_COOLDOWN_MS],
|
||||
[9000, 9000]
|
||||
])('after a 429 with retryAfterMs=%s the key stays off until %dms', async (retryAfterMs, wait) => {
|
||||
h.ensure.mockRejectedValue(rateErr(retryAfterMs));
|
||||
await inbox.ensureDM('amy');
|
||||
await vi.advanceTimersByTimeAsync(wait - 1);
|
||||
expect(inbox.rateLimited['amy']).toBe(true);
|
||||
expect(await inbox.ensureDM('amy')).toBeNull();
|
||||
expect(h.ensure).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(inbox.rateLimited['amy']).toBeUndefined();
|
||||
});
|
||||
|
||||
// Row 9b
|
||||
it('another failure is flagged and allows a retry at once', async () => {
|
||||
h.ensure.mockRejectedValueOnce(new Error('500')).mockResolvedValueOnce(ensureOk(7));
|
||||
expect(await inbox.ensureDM('amy')).toBeNull();
|
||||
expect(inbox.ensureFailed['amy']).toBe(true);
|
||||
expect(inbox.rateLimited['amy']).toBeUndefined();
|
||||
expect(inbox.ensuring.size).toBe(0);
|
||||
expect(await inbox.ensureDM('amy')).toBe(7);
|
||||
expect(inbox.ensureFailed['amy']).toBeUndefined();
|
||||
});
|
||||
|
||||
// K1
|
||||
it('an identity change while the POST is in flight fences it: no load, no number, ensuring cleared', async () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
h.ensure.mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const p = inbox.ensureDM('amy');
|
||||
await flush();
|
||||
expect(inbox.ensuring.has('amy')).toBe(true);
|
||||
changeIdentity();
|
||||
expect(inbox.ensuring.size).toBe(0);
|
||||
resolve(ensureOk(7));
|
||||
expect(await p).toBeNull();
|
||||
expect(h.list).not.toHaveBeenCalled();
|
||||
expect(inbox.ensuring.size).toBe(0);
|
||||
});
|
||||
|
||||
it('an identity change while the reload runs also yields null', async () => {
|
||||
let resolveList!: (v: unknown) => void;
|
||||
h.ensure.mockResolvedValue(ensureOk(7));
|
||||
h.list.mockReturnValue(new Promise((r) => (resolveList = r)));
|
||||
const p = inbox.ensureDM('amy');
|
||||
await flush();
|
||||
changeIdentity();
|
||||
resolveList(listResp(7));
|
||||
expect(await p).toBeNull();
|
||||
});
|
||||
|
||||
// K2
|
||||
it('an identity change in the cool-down clears the flag and its timer', async () => {
|
||||
h.ensure.mockRejectedValue(rateErr());
|
||||
await inbox.ensureDM('amy');
|
||||
expect(inbox.rateLimited['amy']).toBe(true);
|
||||
const timers = vi.getTimerCount();
|
||||
changeIdentity();
|
||||
expect(inbox.rateLimited['amy']).toBeUndefined();
|
||||
expect(vi.getTimerCount()).toBe(timers - 1);
|
||||
h.ensure.mockResolvedValue(ensureOk(7));
|
||||
expect(await inbox.ensureDM('amy')).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatInbox presence ref count (TASK-4044 2b-ii)', () => {
|
||||
const seen = () => h.sessions.mock.calls.length;
|
||||
|
||||
it.each([
|
||||
['start before stop', true],
|
||||
['stop before start', false]
|
||||
])('overlapping owners, %s: one timer, one poll per tick, 0 after the last stop', async (_n, startFirst) => {
|
||||
inbox.startPresence();
|
||||
await flush();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
if (startFirst) {
|
||||
inbox.startPresence();
|
||||
inbox.stopPresence();
|
||||
} else {
|
||||
inbox.stopPresence();
|
||||
inbox.startPresence();
|
||||
}
|
||||
await flush();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
const before = seen();
|
||||
await vi.advanceTimersByTimeAsync(PRESENCE_POLL_MS);
|
||||
expect(seen() - before).toBe(1);
|
||||
inbox.stopPresence();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('a stop with no owner is a no-op and does not go negative', async () => {
|
||||
inbox.stopPresence();
|
||||
inbox.stopPresence();
|
||||
inbox.startPresence();
|
||||
await flush();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
inbox.stopPresence();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
//
|
||||
// Presence (TASK-4044 2b-i): startPresence() polls GET /sessions on its own, apart
|
||||
// from start(), so a page that only wants a presence dot fires no list fetch.
|
||||
//
|
||||
// Roster and ensure (TASK-4044 2b-ii, ruling 018faf64): roster merges presences
|
||||
// with the DM numbers; ensureDM() creates-or-reuses a DM and is fenced by identity.
|
||||
import { untrack } from 'svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
@@ -19,8 +22,25 @@ import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { normalizeAgentName, rollupPresence, type AgentPresence } from '$lib/chat/presence';
|
||||
import type { ChatConversationSummary, LiveSession } from '$lib/types';
|
||||
|
||||
/** One roster row: an agent, how reachable it is, and its DM number if it has one. */
|
||||
export interface RosterEntry {
|
||||
key: string;
|
||||
presence: AgentPresence;
|
||||
number: number | null;
|
||||
}
|
||||
|
||||
const ROSTER_RANK: Record<AgentPresence, number> = {
|
||||
online: 0,
|
||||
busy_uncertain: 1,
|
||||
connected_not_accepting: 2,
|
||||
unknown: 3,
|
||||
offline: 4
|
||||
};
|
||||
|
||||
/** How often the sessions list is read while the tab is visible. */
|
||||
export const PRESENCE_POLL_MS = 10_000;
|
||||
/** How long a rate-limited ensure keeps its button off, at the least. */
|
||||
export const ENSURE_COOLDOWN_MS = 5_000;
|
||||
/** How long a transient failure may keep the last good read before it reads as unknown. */
|
||||
export const PRESENCE_STALE_MS = 30_000;
|
||||
|
||||
@@ -39,20 +59,50 @@ class ChatInboxStore {
|
||||
checkedAt = $state<number | null>(null);
|
||||
|
||||
/**
|
||||
* Presence per agent key over the current conversations, for the roster that
|
||||
* TASK-4044 2b-ii adds. It has no consumer in 2b-i.
|
||||
* Presence per agent key over the current DMs (a group conversation's key is
|
||||
* not an agent to chat with, ruling 018faf64 Q3), plus session-only agents
|
||||
* while the sessions read is good. It feeds the roster.
|
||||
*/
|
||||
presences = $derived(
|
||||
rollupPresence(
|
||||
this.conversations.map((c) => c.agent_key),
|
||||
this.conversations.filter((c) => c.kind === 'direct').map((c) => c.agent_key),
|
||||
this.sessions ?? 'unavailable'
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* The agents to start or open a chat with. No unread here: the badge reads
|
||||
* chatStore.unreadByNumber (Q7).
|
||||
*/
|
||||
roster = $derived.by((): RosterEntry[] => {
|
||||
const numbers = new Map<string, number>();
|
||||
for (const c of this.conversations) {
|
||||
if (c.kind !== 'direct') continue;
|
||||
const k = normalizeAgentName(c.agent_key);
|
||||
if (k !== '' && !numbers.has(k)) numbers.set(k, c.number);
|
||||
}
|
||||
const out: RosterEntry[] = [];
|
||||
for (const [key, presence] of this.presences) {
|
||||
out.push({ key, presence, number: numbers.get(key) ?? null });
|
||||
}
|
||||
return out.sort(
|
||||
(a, b) => ROSTER_RANK[a.presence] - ROSTER_RANK[b.presence] || a.key.localeCompare(b.key)
|
||||
);
|
||||
});
|
||||
|
||||
/** Agent keys with an ensure POST in flight. REPLACED on each change, never mutated (K3). */
|
||||
ensuring = $state<Set<string>>(new Set());
|
||||
/** Keys whose ensure was rate limited and is cooling down. */
|
||||
rateLimited = $state<Record<string, true>>({});
|
||||
/** Keys whose last ensure failed for another reason. */
|
||||
ensureFailed = $state<Record<string, true>>({});
|
||||
|
||||
#seq = 0;
|
||||
#stopRoot: (() => void) | null = null;
|
||||
#presenceSeq = 0;
|
||||
#presenceActive = false;
|
||||
#presenceOwners = 0;
|
||||
#cooldowns = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
#presenceInFlight = false;
|
||||
#presenceStartedAt = 0;
|
||||
#presenceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -67,6 +117,12 @@ class ChatInboxStore {
|
||||
this.conversations = [];
|
||||
this.loaded = false;
|
||||
this.failed = false;
|
||||
// An ensure answer from before this point is fenced; its state goes too (K1, K2).
|
||||
this.ensuring = new Set();
|
||||
this.rateLimited = {};
|
||||
this.ensureFailed = {};
|
||||
for (const t of this.#cooldowns.values()) clearTimeout(t);
|
||||
this.#cooldowns.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,9 +151,13 @@ class ChatInboxStore {
|
||||
);
|
||||
}
|
||||
|
||||
/** Poll the sessions list while the tab is visible. A second call is a no-op. */
|
||||
/**
|
||||
* Poll the sessions list while the tab is visible. Reference counted: pages
|
||||
* that overlap during a navigation each start and stop once, and the poll
|
||||
* runs until the last owner stops.
|
||||
*/
|
||||
startPresence(): void {
|
||||
if (this.#presenceActive) return;
|
||||
if (++this.#presenceOwners > 1) return;
|
||||
this.#presenceActive = true;
|
||||
this.#presenceStartedAt = Date.now();
|
||||
document.addEventListener('visibilitychange', this.#onVisibility);
|
||||
@@ -105,6 +165,8 @@ class ChatInboxStore {
|
||||
}
|
||||
|
||||
stopPresence(): void {
|
||||
if (this.#presenceOwners === 0) return;
|
||||
if (--this.#presenceOwners > 0) return;
|
||||
if (!this.#presenceActive) return;
|
||||
this.#presenceActive = false;
|
||||
// A late answer from before the stop must not land after a later start.
|
||||
@@ -152,6 +214,51 @@ class ChatInboxStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create-or-reuse the DM with an agent and reload the list. Returns the
|
||||
* conversation number, or null when nothing was started (an ensure already in
|
||||
* flight, a failure, a rate limit, or an identity change meanwhile). A 429 is
|
||||
* never retried: the POST has no idempotency key, so the button stays off for
|
||||
* at least ENSURE_COOLDOWN_MS.
|
||||
*/
|
||||
async ensureDM(rawKey: string): Promise<number | null> {
|
||||
const key = normalizeAgentName(rawKey);
|
||||
if (key === '' || this.ensuring.has(key) || this.rateLimited[key]) return null;
|
||||
this.ensuring = new Set(this.ensuring).add(key);
|
||||
this.ensureFailed = withoutKey(this.ensureFailed, key);
|
||||
const isSameIdentity = authStore.identityFence();
|
||||
try {
|
||||
const res = await api.chat.ensureConversation(key);
|
||||
if (!isSameIdentity()) return null;
|
||||
await this.load();
|
||||
if (!isSameIdentity()) return null;
|
||||
return res.conversation.number;
|
||||
} catch (err) {
|
||||
if (!isSameIdentity()) return null;
|
||||
const e = err as { code?: string; retryAfterMs?: number } | null;
|
||||
if (e?.code === 'rate_limited') {
|
||||
this.rateLimited = { ...this.rateLimited, [key]: true };
|
||||
const delay = Math.max(e.retryAfterMs ?? 0, ENSURE_COOLDOWN_MS);
|
||||
this.#cooldowns.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
this.#cooldowns.delete(key);
|
||||
this.rateLimited = withoutKey(this.rateLimited, key);
|
||||
}, delay)
|
||||
);
|
||||
} else {
|
||||
this.ensureFailed = { ...this.ensureFailed, [key]: true };
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (isSameIdentity()) {
|
||||
const next = new Set(this.ensuring);
|
||||
next.delete(key);
|
||||
this.ensuring = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (!authStore.authenticated) return;
|
||||
const mine = ++this.#seq;
|
||||
@@ -170,4 +277,9 @@ class ChatInboxStore {
|
||||
}
|
||||
}
|
||||
|
||||
function withoutKey(rec: Record<string, true>, key: string): Record<string, true> {
|
||||
const { [key]: _gone, ...rest } = rec;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export const chatInbox = new ChatInboxStore();
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
let { children } = $props();
|
||||
let ready = $state(false);
|
||||
|
||||
// A sign-out, or a swap to a signed-out identity, while /messages is open sends
|
||||
// the visitor to /login (TASK-4044 2b-ii, row J). Read-only on authStore.
|
||||
onMount(() =>
|
||||
authStore.onIdentityChange(() => {
|
||||
if (!authStore.authenticated) goto('/login', { replaceState: true });
|
||||
})
|
||||
);
|
||||
|
||||
// The root layout gives /messages no app shell, so signed-out visitors are
|
||||
// sent to /login here, as the console layout does. A guest is not redirected:
|
||||
// the API is owner-scoped and the page leaks nothing.
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import ChatBackLink from '$lib/components/chat/ChatBackLink.svelte';
|
||||
import RosterRow from '$lib/components/chat/RosterRow.svelte';
|
||||
import ConversationList from '$lib/components/chat/ConversationList.svelte';
|
||||
import { chatInbox } from '$lib/stores/chatInbox.svelte';
|
||||
|
||||
// A late POST answer must not pull the user back after they leave (TASK-4044).
|
||||
let mounted = true;
|
||||
|
||||
// The roster's dots poll sessions only while this page is open.
|
||||
onMount(() => {
|
||||
chatInbox.start();
|
||||
return () => chatInbox.stop();
|
||||
chatInbox.startPresence();
|
||||
return () => {
|
||||
mounted = false;
|
||||
chatInbox.stop();
|
||||
chatInbox.stopPresence();
|
||||
};
|
||||
});
|
||||
|
||||
// Open the chat only once the POST has answered: a null means nothing started.
|
||||
async function ensure(key: string): Promise<void> {
|
||||
const n = await chatInbox.ensureDM(key);
|
||||
if (n !== null && mounted) await goto(`/messages/${n}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -19,6 +36,23 @@
|
||||
<h1>Chat</h1>
|
||||
</header>
|
||||
|
||||
{#if chatInbox.roster.length > 0}
|
||||
<ul class="roster" aria-label="Agents">
|
||||
{#each chatInbox.roster as entry (entry.key)}
|
||||
<li>
|
||||
<RosterRow
|
||||
{entry}
|
||||
checkedAt={chatInbox.checkedAt}
|
||||
pending={chatInbox.ensuring.has(entry.key)}
|
||||
rateLimited={!!chatInbox.rateLimited[entry.key]}
|
||||
failed={!!chatInbox.ensureFailed[entry.key]}
|
||||
onensure={ensure}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- The list reloads only when chatStore's unread fetch succeeds, so a failed
|
||||
list fetch beside a good unread fetch has no other way back (TASK-4044 r1):
|
||||
chatStore's retry re-arms only for a failed UNREAD fetch.
|
||||
@@ -46,6 +80,17 @@
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.roster {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--space-6);
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.roster li + li {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ const h = vi.hoisted(() => ({
|
||||
listMessages: vi.fn(),
|
||||
listConversations: vi.fn(),
|
||||
sessions: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
authenticated: true,
|
||||
listeners: [] as Array<(prev: string) => void>,
|
||||
load: vi.fn(),
|
||||
goto: vi.fn(),
|
||||
// Bumped by a test to stand for an identity change: fences taken earlier fail.
|
||||
@@ -31,7 +34,8 @@ vi.mock('$lib/api/client', () => ({
|
||||
getConversation: h.getConversation,
|
||||
markRead: h.markRead,
|
||||
listMessages: h.listMessages,
|
||||
listConversations: h.listConversations
|
||||
listConversations: h.listConversations,
|
||||
ensureConversation: h.ensure
|
||||
},
|
||||
sessions: { list: h.sessions }
|
||||
},
|
||||
@@ -40,12 +44,20 @@ vi.mock('$lib/api/client', () => ({
|
||||
vi.mock('$lib/stores/auth.svelte', () => ({
|
||||
authStore: {
|
||||
load: h.load,
|
||||
authenticated: true,
|
||||
get authenticated() {
|
||||
return h.authenticated;
|
||||
},
|
||||
identityFence: () => {
|
||||
const gen = h.identityGen;
|
||||
return () => h.identityGen === gen;
|
||||
},
|
||||
onIdentityChange: () => () => {}
|
||||
onIdentityChange: (fn: (prev: string) => void) => {
|
||||
h.listeners.push(fn);
|
||||
return () => {
|
||||
const i = h.listeners.indexOf(fn);
|
||||
if (i >= 0) h.listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
}
|
||||
}));
|
||||
vi.mock('$lib/stores/chat.svelte', () => ({
|
||||
@@ -81,7 +93,7 @@ async function settle() {
|
||||
const conv = (n: number) => ({
|
||||
id: `c${n}`,
|
||||
number: n,
|
||||
kind: 'dm',
|
||||
kind: 'direct',
|
||||
title: '',
|
||||
agent_key: `agent-${n}`,
|
||||
last_seq: 0,
|
||||
@@ -93,6 +105,8 @@ const conv = (n: number) => ({
|
||||
|
||||
beforeEach(() => {
|
||||
for (const f of Object.values(h)) if (typeof f === 'function' && 'mockReset' in f) (f as ReturnType<typeof vi.fn>).mockReset();
|
||||
h.authenticated = true;
|
||||
h.chat.unreadByNumber = {};
|
||||
h.load.mockResolvedValue({ authenticated: true });
|
||||
h.listConversations.mockResolvedValue({ conversations: [conv(3)], unread_total: 0 });
|
||||
h.getConversation.mockResolvedValue(conv(3));
|
||||
@@ -105,10 +119,18 @@ beforeEach(() => {
|
||||
chatInbox.failed = false;
|
||||
chatInbox.sessions = null;
|
||||
chatInbox.checkedAt = null;
|
||||
chatInbox.ensuring = new Set();
|
||||
chatInbox.rateLimited = {};
|
||||
chatInbox.ensureFailed = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// cleanup() unmounts every page, which releases its presence owner; an armed
|
||||
// poll after it is a leaked owner (fake timers only: real ones cannot be counted).
|
||||
if (vi.isFakeTimers()) expect(vi.getTimerCount(), 'a page left a presence owner started').toBe(0);
|
||||
chatInbox.stopPresence();
|
||||
vi.useRealTimers();
|
||||
page.params = {};
|
||||
});
|
||||
|
||||
@@ -203,6 +225,165 @@ describe('/messages page (TASK-4044)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Row J (TASK-4044 2b-ii): an identity change to signed-out leaves the page.
|
||||
describe('/messages layout identity change (TASK-4044 2b-ii)', () => {
|
||||
async function mountAndChange(authenticated: boolean) {
|
||||
render(MessagesLayout, { props: { children: child } });
|
||||
await settle();
|
||||
h.goto.mockClear();
|
||||
h.authenticated = authenticated;
|
||||
for (const fn of [...h.listeners]) fn('previous-user');
|
||||
}
|
||||
|
||||
it('redirects to /login when the new identity is signed out', async () => {
|
||||
await mountAndChange(false);
|
||||
expect(h.goto).toHaveBeenCalledWith('/login', { replaceState: true });
|
||||
});
|
||||
|
||||
it('does not redirect on a swap to another signed-in user', async () => {
|
||||
await mountAndChange(true);
|
||||
expect(h.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops listening when the layout unmounts', async () => {
|
||||
const base = h.listeners.length;
|
||||
const { unmount } = render(MessagesLayout, { props: { children: child } });
|
||||
await settle();
|
||||
expect(h.listeners.length).toBe(base + 1);
|
||||
unmount();
|
||||
expect(h.listeners.length).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/messages roster (TASK-4044 2b-ii)', () => {
|
||||
const live = (agent: string) => ({ id: `s-${agent}`, agent, state: 'online', armed: true, connected_at: '' });
|
||||
const ghostButton = (c: HTMLElement) => [...c.querySelectorAll('button')].find((b) => b.textContent?.includes('ghost')) as HTMLButtonElement;
|
||||
|
||||
beforeEach(() => {
|
||||
h.sessions.mockResolvedValue({ sessions: [live('ghost')], count: 1 });
|
||||
});
|
||||
|
||||
it('shows a link with the store unread badge for a DM, and a Start chat button for a session-only agent', async () => {
|
||||
h.chat.unreadByNumber = { 3: 5 };
|
||||
const { container } = render(MessagesPage);
|
||||
await settle();
|
||||
const link = container.querySelector('ul[aria-label="Agents"] a[href="/messages/3"]')!;
|
||||
expect(link.querySelector('.unread-badge')!.textContent).toBe('5');
|
||||
expect(link.querySelector('.presence-dot')).not.toBeNull();
|
||||
const btn = ghostButton(container);
|
||||
expect(btn.textContent).toContain('Start chat');
|
||||
expect(btn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('goes to the new chat only after the POST resolves, and not on a failure', async () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
h.ensure.mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const { container } = render(MessagesPage);
|
||||
await settle();
|
||||
ghostButton(container).click();
|
||||
await settle();
|
||||
expect(h.goto).not.toHaveBeenCalled();
|
||||
resolve({ conversation: conv(7), created: true });
|
||||
await settle();
|
||||
expect(h.goto).toHaveBeenCalledWith('/messages/7');
|
||||
});
|
||||
|
||||
it('does not navigate when the page was left before the POST resolved', async () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
h.ensure.mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const { container, unmount } = render(MessagesPage);
|
||||
await settle();
|
||||
ghostButton(container).click();
|
||||
await settle();
|
||||
unmount();
|
||||
resolve({ conversation: conv(7), created: true });
|
||||
await settle();
|
||||
expect(h.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not navigate when the ensure fails', async () => {
|
||||
h.ensure.mockRejectedValue(new Error('500'));
|
||||
const { container } = render(MessagesPage);
|
||||
await settle();
|
||||
ghostButton(container).click();
|
||||
await settle();
|
||||
expect(h.goto).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Couldn't start the chat. Try again.");
|
||||
expect(ghostButton(container).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('a double click posts once and the button is disabled meanwhile', async () => {
|
||||
h.ensure.mockReturnValue(new Promise(() => {}));
|
||||
const { container } = render(MessagesPage);
|
||||
await settle();
|
||||
const btn = ghostButton(container);
|
||||
btn.click();
|
||||
await settle();
|
||||
expect(btn.disabled).toBe(true);
|
||||
btn.click();
|
||||
await settle();
|
||||
expect(h.ensure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// K3: the button must come back in the DOM once the ensure settles.
|
||||
it('re-enables the button in the DOM after a settled ensure', async () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
h.ensure.mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const { container } = render(MessagesPage);
|
||||
await settle();
|
||||
ghostButton(container).click();
|
||||
await settle();
|
||||
expect(ghostButton(container).disabled).toBe(true);
|
||||
h.ensure.mockReset();
|
||||
resolve({ conversation: conv(7), created: true });
|
||||
await settle();
|
||||
expect(ghostButton(container).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('a 429 shows the copy and keeps the button off until the cool-down ends', async () => {
|
||||
vi.useFakeTimers();
|
||||
h.ensure.mockRejectedValue(Object.assign(new Error('rl'), { code: 'rate_limited' }));
|
||||
const { container } = render(MessagesPage);
|
||||
await settle();
|
||||
ghostButton(container).click();
|
||||
await settle();
|
||||
expect(container.textContent).toContain('Too many new chats right now. Try again in a few seconds.');
|
||||
expect(ghostButton(container).disabled).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await settle();
|
||||
expect(ghostButton(container).disabled).toBe(false);
|
||||
expect(container.textContent).not.toContain('Too many new chats');
|
||||
});
|
||||
});
|
||||
|
||||
describe('presence ref count across a navigation (TASK-4044 2b-ii)', () => {
|
||||
it.each([
|
||||
['start before stop', true],
|
||||
['stop before start', false]
|
||||
])('/messages to /messages/[n], %s: one timer, one poll per tick', async (_n, startFirst) => {
|
||||
vi.useFakeTimers();
|
||||
page.params = { n: '3' };
|
||||
const list = render(MessagesPage);
|
||||
await settle();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
let thread!: ReturnType<typeof render>;
|
||||
if (startFirst) {
|
||||
thread = render(ThreadPage);
|
||||
list.unmount();
|
||||
} else {
|
||||
list.unmount();
|
||||
thread = render(ThreadPage);
|
||||
}
|
||||
await settle();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
const before = h.sessions.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(h.sessions.mock.calls.length - before).toBe(1);
|
||||
thread.unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/messages/[n] placeholder (TASK-4044)', () => {
|
||||
it('shows the notice and the agent for a real conversation', async () => {
|
||||
page.params = { n: '3' };
|
||||
|
||||
Reference in New Issue
Block a user