Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ This is a TypeScript CLI (`tw`) for Twist messaging, built with Commander.js.
- **Named flag aliases**: Where commands accept positional `[workspace-ref]`, the `--workspace` flag is also accepted. Error if both positional and flag are provided
- **JSON output on mutating commands**: Mutating commands (create, update, delete, archive) should support `--json` output where it provides scripting value. Commands that return an object from the API (create/update) should also support `--full`. Commands where the API returns void should output a minimal status object (e.g. `{ id, deleted: true }` or `{ id, isArchived: true }`). Extend `MutationOptions` in `src/lib/options.ts` (which already includes `json` and `full`) rather than adding these fields ad hoc. Use `formatJson()` from `src/lib/output.ts` for the output. See `src/commands/away.ts` as the reference implementation.
- **Spinner messages**: When adding new SDK method calls, add a corresponding entry in the `API_SPINNER_MESSAGES` map in `src/lib/api.ts`. Every user-facing API call should have a spinner message so the CLI shows progress feedback.
- **Batch API responses**: When calling `client.batch(...)`, never access `.data` directly on a batch result. Use these helpers from `src/lib/api.ts`:
- `assertBatchData(response, label)` — single result; throws `CliError` on any failure.
- `buildBatchNameMap(ids, responses, label)` — strict parallel lookup; use when every id must resolve (e.g. channels).
- `buildOptionalBatchNameMap(ids, responses, label)` — tolerant parallel lookup; skips entries with `data: null` and a success code (e.g. deleted users) but still throws on real API errors. Callers must fall back via `userMap.get(id) ?? \`user:${id}\``. Use for user lookups so a single missing user doesn't abort the whole command.

See `src/commands/inbox.ts` (strict) and `src/commands/thread/view.ts` (tolerant) for reference.

## Error Handling

Expand Down
66 changes: 60 additions & 6 deletions src/commands/conversation/conversation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describeEmptyMachineOutput } from '@doist/cli-core/testing'
import type { BatchResponse as TwistBatchResponse } from '@doist/twist-sdk'
import { Command } from 'commander'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CliError } from '../../lib/errors.js'
Expand All @@ -15,7 +16,10 @@ const refsMocks = vi.hoisted(() => ({
resolveUserRefs: vi.fn(),
}))

vi.mock('../../lib/api.js', () => apiMocks)
vi.mock('../../lib/api.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../lib/api.js')>()),
...apiMocks,
}))

vi.mock('../../lib/refs.js', () => refsMocks)

Expand Down Expand Up @@ -63,6 +67,8 @@ function createConversation(id: number, userIds: number[], lastActive: string):
}
}

type BatchResult = Pick<TwistBatchResponse<unknown>, 'code' | 'data'>

function createClient({
activeConversations = [],
archivedConversations = [],
Expand Down Expand Up @@ -165,16 +171,19 @@ function createClient({
userId?: number
conversationId?: number
}>
) =>
requests.map((request) => {
): Promise<BatchResult[]> =>
requests.map((request): BatchResult => {
if (request.kind === 'conversation' && request.id) {
return { data: conversationsById.get(request.id) }
return { code: 200, data: conversationsById.get(request.id) }
}
if (request.kind === 'messages') {
return { data: messagesByConversation[request.conversationId ?? -1] ?? [] }
return {
code: 200,
data: messagesByConversation[request.conversationId ?? -1] ?? [],
}
}
if (request.kind === 'user' && request.userId) {
return { data: users[request.userId] }
return { code: 200, data: users[request.userId] }
}
throw new Error(`Unexpected batch request: ${JSON.stringify(request)}`)
}),
Expand Down Expand Up @@ -551,6 +560,51 @@ describe('conversation view machine output', () => {
})
})

describe('conversation view with failed batch response', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('throws a clear error when a batched user lookup fails', async () => {
const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
const messages = [
{
id: 99,
content: '**hello**',
creator: 2,
conversationId: 42,
posted: new Date('2026-03-08T10:05:00.000Z'),
reactions: [],
},
]
const client = createClient({
activeConversations: [conversation],
messagesByConversation: { 42: messages },
users: {
1: { id: 1, name: 'Me' },
2: { id: 2, name: 'Alice Example' },
},
})

client.batch
.mockResolvedValueOnce([
{ code: 200, data: conversation },
{ code: 200, data: messages },
])
.mockResolvedValueOnce([
{ code: 200, data: { id: 1, name: 'Me' } },
{ code: 403, data: { errorString: 'User lookup failed' } },
])
apiMocks.getTwistClient.mockResolvedValue(client)

const program = createProgram()

await expect(
program.parseAsync(['node', 'tw', 'conversation', 'view', '42']),
).rejects.toThrow('Failed to fetch user 2: User lookup failed')
})
})

describe('conversation mute', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
7 changes: 4 additions & 3 deletions src/commands/conversation/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Conversation } from '@doist/twist-sdk'
import chalk from 'chalk'
import { getTwistClient } from '../../lib/api.js'
import { buildOptionalBatchNameMap, getTwistClient } from '../../lib/api.js'
import { formatRelativeDate } from '../../lib/dates.js'
import { CliError } from '../../lib/errors.js'
import { isAccessible } from '../../lib/global-args.js'
Expand Down Expand Up @@ -179,11 +179,12 @@ export async function listConversationsWithUser(
}
}

const userCalls = [...userIds].map((userId) =>
const userIdList = [...userIds]
const userCalls = userIdList.map((userId) =>
client.workspaceUsers.getUserById({ workspaceId, userId }, { batch: true }),
)
const userResponses = await client.batch(...userCalls)
const userMap = new Map(userResponses.map((response) => [response.data.id, response.data.name]))
const userMap = buildOptionalBatchNameMap(userIdList, userResponses, 'user')

const output = conversations.map((conversation) => ({
...conversation,
Expand Down
19 changes: 15 additions & 4 deletions src/commands/conversation/unread.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import chalk from 'chalk'
import { getCurrentWorkspaceId, getTwistClient } from '../../lib/api.js'
import {
assertBatchData,
buildOptionalBatchNameMap,
getCurrentWorkspaceId,
getTwistClient,
} from '../../lib/api.js'
import { CliError } from '../../lib/errors.js'
import { isAccessible } from '../../lib/global-args.js'
import { colors, formatJson, formatNdjson, printEmpty } from '../../lib/output.js'
Expand Down Expand Up @@ -39,7 +44,12 @@ export async function showUnread(
client.conversations.getConversation(uc.conversationId, { batch: true }),
)
const conversationResponses = await client.batch(...conversationCalls)
const conversations = conversationResponses.map((r) => r.data)
const conversations = unreadConversations.map((conversation, index) =>
assertBatchData(
conversationResponses[index],
`conversation ${conversation.conversationId}`,
),
)

const userIds = new Set<number>()
for (const conv of conversations) {
Expand All @@ -48,11 +58,12 @@ export async function showUnread(
}
}

const userCalls = [...userIds].map((id) =>
const userIdList = [...userIds]
const userCalls = userIdList.map((id) =>
client.workspaceUsers.getUserById({ workspaceId, userId: id }, { batch: true }),
)
const userResponses = await client.batch(...userCalls)
const userMap = new Map(userResponses.map((r) => [r.data.id, r.data.name]))
const userMap = buildOptionalBatchNameMap(userIdList, userResponses, 'user')

if (options.json) {
const output = conversations.map((c) => ({
Expand Down
17 changes: 11 additions & 6 deletions src/commands/conversation/view.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import chalk from 'chalk'
import { getTwistClient } from '../../lib/api.js'
import { assertBatchData, buildOptionalBatchNameMap, getTwistClient } from '../../lib/api.js'
import { formatRelativeDate } from '../../lib/dates.js'
import { renderMarkdown } from '../../lib/markdown.js'
import { colors, filterEntityFields } from '../../lib/output.js'
Expand All @@ -25,18 +25,23 @@ export async function viewConversation(
),
)

const conversation = convResponse.data
const messages = messagesResponse.data
const conversation = assertBatchData(convResponse, `conversation ${conversationId}`)
const messages = assertBatchData(
messagesResponse,
`messages for conversation ${conversationId}`,
)

const userIds = new Set<number>([...conversation.userIds, ...messages.map((m) => m.creator)])
const userCalls = [...userIds].map((id) =>
const userIds = [
...new Set<number>([...conversation.userIds, ...messages.map((m) => m.creator)]),
]
const userCalls = userIds.map((id) =>
client.workspaceUsers.getUserById(
{ workspaceId: conversation.workspaceId, userId: id },
{ batch: true },
),
)
const userResponses = await client.batch(...userCalls)
const userMap = new Map(userResponses.map((r) => [r.data.id, r.data.name]))
const userMap = buildOptionalBatchNameMap(userIds, userResponses, 'user')
const conversationOutput = {
...conversation,
participantNames: conversation.userIds.map((id) => userMap.get(id)),
Expand Down
56 changes: 49 additions & 7 deletions src/commands/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ const apiMocks = vi.hoisted(() => ({
getCurrentWorkspaceId: vi.fn(),
}))

vi.mock('../lib/api.js', () => ({
getTwistClient: apiMocks.getTwistClient,
getCurrentWorkspaceId: apiMocks.getCurrentWorkspaceId,
vi.mock('../lib/api.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../lib/api.js')>()),
...apiMocks,
}))

vi.mock('../lib/refs.js', () => ({
Expand Down Expand Up @@ -60,7 +60,10 @@ describe('inbox --archive-filter', () => {
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
mockGetInbox.mockReturnValue({ data: [] })
mockGetUnread.mockReturnValue({ data: [] })
mockBatch.mockResolvedValue([{ data: [] }, { data: [] }])
mockBatch.mockResolvedValue([
{ code: 200, data: [] },
{ code: 200, data: [] },
])
apiMocks.getTwistClient.mockResolvedValue({
inbox: { getInbox: mockGetInbox },
threads: { getUnread: mockGetUnread },
Expand Down Expand Up @@ -121,7 +124,10 @@ describeEmptyMachineOutput('inbox empty output', {
vi.clearAllMocks()
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
emptyInboxMockBatch.mockImplementation((..._calls: unknown[]) =>
Promise.resolve([{ data: [] }, { data: [] }]),
Promise.resolve([
{ code: 200, data: [] },
{ code: 200, data: [] },
]),
)
apiMocks.getTwistClient.mockResolvedValue({
inbox: { getInbox: vi.fn().mockReturnValue({ data: [] }) },
Expand Down Expand Up @@ -152,8 +158,11 @@ describe('inbox empty output (channel filter)', () => {
url: 'http://example/t',
}
mockBatch
.mockResolvedValueOnce([{ data: [thread] }, { data: [] }])
.mockResolvedValueOnce([{ data: { id: 10, name: 'engineering' } }])
.mockResolvedValueOnce([
{ code: 200, data: [thread] },
{ code: 200, data: [] },
])
.mockResolvedValueOnce([{ code: 200, data: { id: 10, name: 'engineering' } }])
apiMocks.getTwistClient.mockResolvedValue({
inbox: { getInbox: vi.fn() },
threads: { getUnread: vi.fn() },
Expand All @@ -171,3 +180,36 @@ describe('inbox empty output (channel filter)', () => {
expect(logSpy).toHaveBeenCalledWith('[]')
})
})

describe('inbox batch errors', () => {
beforeEach(() => {
vi.clearAllMocks()
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
})

it('surfaces the API error instead of crashing when a batch request fails', async () => {
const mockBatch = vi.fn().mockResolvedValue([
{ code: 400, data: { errorString: 'limit must be less than or equal to 500' } },
{ code: 200, data: [] },
])
apiMocks.getTwistClient.mockResolvedValue({
inbox: {
getInbox: vi.fn((_args: unknown, options?: { batch?: boolean }) =>
options?.batch ? { kind: 'inbox' } : Promise.resolve([]),
),
},
threads: {
getUnread: vi.fn((_workspaceId: number, options?: { batch?: boolean }) =>
options?.batch ? { kind: 'unread' } : Promise.resolve([]),
),
},
batch: mockBatch,
})

const program = createProgram()

await expect(
program.parseAsync(['node', 'tw', 'inbox', '--unread', '--limit', '1000']),
).rejects.toThrow('Failed to fetch inbox threads: limit must be less than or equal to 500')
})
})
17 changes: 12 additions & 5 deletions src/commands/inbox.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { ArchiveFilter } from '@doist/twist-sdk'
import chalk from 'chalk'
import { Command, Option } from 'commander'
import { getCurrentWorkspaceId, getTwistClient } from '../lib/api.js'
import {
assertBatchData,
buildBatchNameMap,
getCurrentWorkspaceId,
getTwistClient,
} from '../lib/api.js'
import { withCaseInsensitiveChoices } from '../lib/completion.js'
import { formatRelativeDate } from '../lib/dates.js'
import { CliError } from '../lib/errors.js'
Expand Down Expand Up @@ -39,7 +44,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions
const client = await getTwistClient()
const limit = options.limit ? parseInt(options.limit, 10) : 50

const [threads, unreadData] = await client.batch(
const [threadsResponse, unreadResponse] = await client.batch(
client.inbox.getInbox(
{
workspaceId,
Expand All @@ -53,8 +58,10 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions
client.threads.getUnread(workspaceId, { batch: true }),
)

const unreadThreadIds = new Set(unreadData.data.map((u) => u.threadId))
let inboxThreads = threads.data.map((t) => ({
const threads = assertBatchData(threadsResponse, 'inbox threads')
const unreadData = assertBatchData(unreadResponse, 'unread threads')
const unreadThreadIds = new Set(unreadData.map((u) => u.threadId))
let inboxThreads = threads.map((t) => ({
...t,
isUnread: unreadThreadIds.has(t.id),
}))
Expand All @@ -71,7 +78,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions
const channelIds = [...new Set(inboxThreads.map((t) => t.channelId))]
const channelCalls = channelIds.map((id) => client.channels.getChannel(id, { batch: true }))
const channelResponses = await client.batch(...channelCalls)
const channelMap = new Map(channelResponses.map((r) => [r.data.id, r.data.name]))
const channelMap = buildBatchNameMap(channelIds, channelResponses, 'channel')

if (!includePrivateChannels()) {
const publicIds = await getPublicChannelIds(workspaceId)
Expand Down
Loading
Loading