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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ This file records user-visible and operational changes that reach `main`. Pull r

## Unreleased

- Use saved OAuth sessions when testing MCP connections and keep the MCP editor tabs sized to their labels.

- Standardize MCP and Skills cards and editors on shared shadcn components with flat layouts, accessible Active switches, explicit Manage actions, and deletion confirmation.

- Preserve OAuth MCP authentication, start assigned-agent tasks through the existing collaboration thread and agent-turn pipeline with stable identity and cautious failure reporting, and place contextual/docked resize handles in their gutters.
- Add desktop dictation microphone selection with live device refresh and capture status, alongside native PCM capture without a network admission wait, a compact audio-reactive pill, cancellation and temporary retry/copy recovery.

- Switch existing accounts to double sidebar once across all workspaces, while allowing subsequent single-sidebar opt-outs.
Expand Down
2 changes: 1 addition & 1 deletion convex/agents/agentThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ async function callerArchivedAt(
return participant?.archivedAt
}

async function insertThread(
export async function insertThread(
ctx: MutationCtx,
args: { workspaceId: string; userId: string; title: string; dmIdentityKey?: string },
actor: Doc<'workspacePrincipals'>,
Expand Down
117 changes: 68 additions & 49 deletions convex/collaboration/directMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,58 +567,77 @@ export const addMessage = mutation({
serverSecret: v.optional(v.string()),
},
handler: async (ctx, args) => {
const access = await requireConversationAccess(ctx, args)
if ((access.conversation.conversationType ?? 'personal') === 'personal') {
throw new Error('COLLABORATION_CONVERSATION_REQUIRED')
}
if (access.conversation.externalPlatform) {
throw new Error('SURFACE_CONVERSATION_READ_ONLY')
}
if (args.threadRootMessageId) {
await getMessageForThread(ctx, args.conversationId, args.threadRootMessageId)
}
if (args.clientNonce) {
const existing = await ctx.db.query('conversationMessages')
.withIndex('by_conversationId', (q) => q.eq('conversationId', args.conversationId))
.collect()
const match = existing.find((message) => message.clientNonce === args.clientNonce)
if (match) return match._id
}
const now = Date.now()
const messageId = await ctx.db.insert('conversationMessages', {
conversationId: args.conversationId,
userId: args.actorUserId,
authorKind: 'human',
authorPrincipalId: access.actor.principalId,
turnId: args.turnId,
role: 'user',
mode: 'act',
content: args.content,
contentType: 'text',
parts: args.parts,
replyToTurnId: args.replyToTurnId,
replySnippet: args.replySnippet,
clientNonce: args.clientNonce,
threadRootMessageId: args.threadRootMessageId,
status: 'completed',
createdAt: now,
updatedAt: now,
})
await ctx.db.patch(args.conversationId, { lastModified: now, updatedAt: now })
await recordConversationEvent(ctx, {
conversationId: args.conversationId,
workspaceId: args.workspaceId,
userId: args.actorUserId,
type: 'message.created',
messageId,
})
await ctx.scheduler.runAfter(0, internal.knowledge.knowledge.reindexMessageInternal, {
messageId,
})
return messageId
return await insertCollaborationMessage(ctx, args)
},
})

export async function insertCollaborationMessage(
ctx: MutationCtx,
args: {
actorUserId: string
clientNonce?: string
content: string
conversationId: Id<'conversations'>
parts?: Doc<'conversationMessages'>['parts']
replySnippet?: string
replyToTurnId?: string
threadRootMessageId?: Id<'conversationMessages'>
turnId: string
workspaceId: string
serverSecret?: string
},
) {
const access = await requireConversationAccess(ctx, args)
if ((access.conversation.conversationType ?? 'personal') === 'personal') {
throw new Error('COLLABORATION_CONVERSATION_REQUIRED')
}
if (access.conversation.externalPlatform) {
throw new Error('SURFACE_CONVERSATION_READ_ONLY')
}
if (args.threadRootMessageId) {
await getMessageForThread(ctx, args.conversationId, args.threadRootMessageId)
}
if (args.clientNonce) {
const existing = await ctx.db.query('conversationMessages')
.withIndex('by_conversationId', (q) => q.eq('conversationId', args.conversationId))
.collect()
const match = existing.find((message) => message.clientNonce === args.clientNonce)
if (match) return match._id
}
const now = Date.now()
const messageId = await ctx.db.insert('conversationMessages', {
conversationId: args.conversationId,
userId: args.actorUserId,
authorKind: 'human',
authorPrincipalId: access.actor.principalId,
turnId: args.turnId,
role: 'user',
mode: 'act',
content: args.content,
contentType: 'text',
parts: args.parts,
replyToTurnId: args.replyToTurnId,
replySnippet: args.replySnippet,
clientNonce: args.clientNonce,
threadRootMessageId: args.threadRootMessageId,
status: 'completed',
createdAt: now,
updatedAt: now,
})
await ctx.db.patch(args.conversationId, { lastModified: now, updatedAt: now })
await recordConversationEvent(ctx, {
conversationId: args.conversationId,
workspaceId: args.workspaceId,
userId: args.actorUserId,
type: 'message.created',
messageId,
})
await ctx.scheduler.runAfter(0, internal.knowledge.knowledge.reindexMessageInternal, {
messageId,
})
return messageId
}

/** Persists a streamed workspace-agent reply after validating both the human
* invoker's room access and the agent's active participation in that room. */
export const addAgentMessage = mutation({
Expand Down
11 changes: 11 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,17 @@ export default defineSchema({
rank: v.string(),
/** A workspace principal: a person now, an agent later. */
assigneePrincipalId: v.optional(v.string()),
assignmentExecution: v.optional(v.object({
assignedByUserId: v.string(),
assigneePrincipalId: v.string(),
generation: v.number(),
status: v.union(v.literal('pending'), v.literal('starting'), v.literal('started'), v.literal('failed')),
conversationId: v.optional(v.id('conversations')),
messageId: v.optional(v.id('conversationMessages')),
turnId: v.optional(v.string()),
clientNonce: v.optional(v.string()),
error: v.optional(v.string()),
})),
reviewerUserId: v.optional(v.string()),
startDate: v.optional(v.string()),
dueDate: v.optional(v.string()),
Expand Down
72 changes: 71 additions & 1 deletion convex/work.convex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const q = (name: string) => makeFunctionReference<'query'>(name)
const m = (name: string) => makeFunctionReference<'mutation'>(name)

type Convex = ReturnType<typeof convexTest>
type Item = { _id: string; title: string; status: string; rank: string; scope: string; parentId?: string; assigneePrincipalId?: string; archivedAt?: number; blocked: boolean; updatedAt: number }
type Item = { _id: string; title: string; status: string; rank: string; scope: string; parentId?: string; assigneePrincipalId?: string; assignmentExecution?: { assignedByUserId: string; assigneePrincipalId: string; generation: number; status: 'pending' | 'starting' | 'started' | 'failed'; conversationId?: string; messageId?: string; turnId?: string; clientNonce?: string }; archivedAt?: number; blocked: boolean; updatedAt: number }

/** A workspace with an owner, an admin, two members, a guest, and an agent. */
async function seed(convex: Convex, policy?: Record<string, unknown>) {
Expand Down Expand Up @@ -82,6 +82,7 @@ function api(convex: Convex) {
return {
create: (userId: string, args: Record<string, unknown>) => convex.mutation(m('work/workItems:create'), { ...auth(userId), title: 'Task', ...args }) as Promise<Item>,
update: (userId: string, id: string, args: Record<string, unknown>) => convex.mutation(m('work/workItems:update'), { ...auth(userId), id, ...args }) as Promise<Item>,
prepareAssignment: (userId: string, id: string, assigneePrincipalId: string, generation: number, content: string) => convex.mutation(m('work/workItems:prepareAssignment'), { ...auth(userId), id, assigneePrincipalId, generation, content }) as Promise<{ execution: NonNullable<Item['assignmentExecution']>; claimed: boolean } | null>,
move: (userId: string, id: string, args: Record<string, unknown>) => convex.mutation(m('work/workItems:move'), { ...auth(userId), id, ...args }) as Promise<Item>,
list: async (userId: string, view?: string) => (await convex.query(q('work/workItems:list'), { ...auth(userId), ...(view ? { view } : {}) })) as Item[],
titles: async (userId: string, view?: string) => (await convex.query(q('work/workItems:list'), { ...auth(userId), ...(view ? { view } : {}) }) as Item[]).map((i) => i.title),
Expand Down Expand Up @@ -477,6 +478,75 @@ describe('fields, assignees and validation', () => {
expect(await work.update('alice', shared._id, { reviewerUserId: 'owner' })).toMatchObject({ reviewerUserId: 'owner' })
})

test('assignment preparation is atomic and reuses one agent thread and task message', async () => {
const convex = convexTest(schema, modules)
await seed(convex)
const work = api(convex)
const task = await work.create('alice', { title: 'Assigned task', scope: 'workspace', assigneePrincipalId: 'p-agent' })
const generation = task.assignmentExecution!.generation
const [first, second] = await Promise.all([
work.prepareAssignment('alice', task._id, 'p-agent', generation, 'Task instructions'),
work.prepareAssignment('alice', task._id, 'p-agent', generation, 'Task instructions'),
])
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect([first!.claimed, second!.claimed].filter(Boolean)).toHaveLength(1)
expect(first!.execution.conversationId).toBe(second!.execution.conversationId)
expect(first!.execution.messageId).toBe(second!.execution.messageId)
expect(first!.execution.clientNonce).toBe(`work-assignment:${task._id}:${generation}`)

const stored = await convex.run(async (ctx) => {
const conversation = await ctx.db.get(first!.execution.conversationId as never)
const messages = await ctx.db.query('conversationMessages')
.withIndex('by_conversationId', (q) => q.eq('conversationId', conversation!._id))
.collect()
const participants = await ctx.db.query('conversationParticipants')
.withIndex('by_conversationId_principalId', (q) => q.eq('conversationId', conversation!._id))
.collect()
return { conversation, messages, participants }
})
expect(stored.conversation).toMatchObject({ agentId: 'agent-1', workspaceId, userId: 'alice' })
expect(stored.messages).toHaveLength(1)
expect(stored.messages[0]).toMatchObject({ content: 'Task instructions', clientNonce: first!.execution.clientNonce, turnId: `work_assignment_${task._id}_${generation}` })
expect(stored.participants).toHaveLength(2)
expect(stored.participants.map((participant) => participant.principalId)).toEqual(expect.arrayContaining(['p-alice', 'p-agent']))
})

test('stale and archived agent assignments cannot prepare a run', async () => {
const convex = convexTest(schema, modules)
await seed(convex)
await addAgent(convex, { principalId: 'p-second-agent', agentId: 'second-agent', creatorPrincipalId: 'p-owner', visibility: 'workspace' })
const work = api(convex)
const task = await work.create('alice', { title: 'Change assignment', scope: 'workspace', assigneePrincipalId: 'p-agent' })
const staleGeneration = task.assignmentExecution!.generation
const reassigned = await work.update('alice', task._id, { assigneePrincipalId: 'p-second-agent' })
expect(await work.prepareAssignment('alice', task._id, 'p-agent', staleGeneration, 'Stale prompt')).toBeNull()
expect(reassigned.assignmentExecution?.assigneePrincipalId).toBe('p-second-agent')

await work.archive('alice', task._id)
expect(await work.prepareAssignment(
'alice',
task._id,
'p-second-agent',
reassigned.assignmentExecution!.generation,
'Archived prompt',
)).toBeNull()
})

test('agent-attributed task edits keep event identity and cannot bypass personal-task access', async () => {
const convex = convexTest(schema, modules)
await seed(convex)
const work = api(convex)
const shared = await work.create('alice', { title: 'Shared', scope: 'workspace' })
await work.update('alice', shared._id, { status: 'progress', actingAgentPrincipalId: 'p-agent' })
expect((await work.get('alice', shared._id))?.status).toBe('progress')
expect((await work.events('alice', shared._id)).at(-1)?.actorPrincipalId).toBe('p-agent')

const privateTask = await work.create('alice', { title: 'Private' })
await expect(work.update('bob', privateTask._id, { status: 'done', actingAgentPrincipalId: 'p-agent' })).rejects.toThrow(/Unauthorized/)
expect((await work.get('alice', privateTask._id))?.status).toBe('todo')
})

test('the picker context lists people and visible agents only', async () => {
const convex = convexTest(schema, modules)
await seed(convex)
Expand Down
Loading
Loading