Skip to content

Commit b1cd4a9

Browse files
committed
fix(copilot): address review findings on table view tools
- edit_table_view resolves the view's table under a workspace-only context (no table scope exists yet for the delegated principal), then re-enters the table-scoped read and update with that id - updateTableView takes the per-table views lock when promoting, so it serializes with default-on-create instead of racing the unique index - the View N fallback is chosen inside the locked create - unknown column names are classified as validation errors in the shared translation, so the model sees which column it got wrong - pending view pins are reset when a chat is torn down or switched - add and reorder share one chat-resource item schema; reorder merges incoming entries with stored ones so pins and paths survive - mergeChatResource keeps every field the newcomer defines - the pin merge runs for every pinned upsert, not gated on wasAdded
1 parent cd339c8 commit b1cd4a9

16 files changed

Lines changed: 294 additions & 131 deletions

File tree

apps/sim/app/api/copilot/chat/resources/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,13 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
135135
const existing = sanitizeChatResources(
136136
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
137137
)
138-
const canonicalOrder = sanitizeChatResources(newOrder)
139-
const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`))
138+
// The client echoes the tabs it holds; anything it does not carry (a view
139+
// pin, a path) is taken from the stored entry rather than dropped.
140+
const existingByKey = new Map(existing.map((r) => [`${r.type}:${r.id}`, r]))
141+
const canonicalOrder = sanitizeChatResources(newOrder).map((r) =>
142+
mergeChatResource(existingByKey.get(`${r.type}:${r.id}`), r)
143+
)
144+
const existingKeys = new Set(existingByKey.keys())
140145
const newKeys = new Set(canonicalOrder.map((r) => `${r.type}:${r.id}`))
141146

142147
if (existingKeys.size !== newKeys.size || ![...existingKeys].every((k) => newKeys.has(k))) {

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,12 @@ describe('handleResourceEvent saved-view pins', () => {
141141
title: 'Invoices',
142142
viewId: 'view-1',
143143
})
144-
expect(deps.setResources).not.toHaveBeenCalled()
144+
// The pin merge always runs; on a list that lacks the table it is a no-op.
145+
const updater = (deps.setResources as ReturnType<typeof vi.fn>).mock.calls[0][0] as (
146+
current: MothershipResource[]
147+
) => MothershipResource[]
148+
const others: MothershipResource[] = [{ type: 'file', id: 'file-1', title: 'notes.md' }]
149+
expect(updater(others)).toBe(others)
145150
expect(useTableViewPinStore.getState().pins['tbl-1']?.viewId).toBe('view-1')
146151
expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith(
147152
deps.queryClient,

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,17 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
122122
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
123123
}
124124
if (pinnedViewId) {
125-
if (!wasAdded) {
126-
// The tab already exists: carry the newest pin so a remount adopts it.
127-
setResources((current) =>
128-
current.some((r) => r.type === 'table' && r.id === resource.id && r.viewId !== pinnedViewId)
129-
? current.map((r) =>
130-
r.type === 'table' && r.id === resource.id ? { ...r, viewId: pinnedViewId } : r
131-
)
132-
: current
133-
)
134-
}
125+
// Carry the newest pin on an existing tab so a remount adopts it. Not gated
126+
// on `wasAdded`: two upserts in one render both read the stale ref and both
127+
// report "added", while only the first updater actually inserted — the
128+
// updater is idempotent, so it simply runs every time.
129+
setResources((current) =>
130+
current.some((r) => r.type === 'table' && r.id === resource.id && r.viewId !== pinnedViewId)
131+
? current.map((r) =>
132+
r.type === 'table' && r.id === resource.id ? { ...r, viewId: pinnedViewId } : r
133+
)
134+
: current
135+
)
135136
// Consumed by the embedded table once its views list carries the view —
136137
// which may be after the refetch below lands, or after the tab first opens.
137138
useTableViewPinStore.getState().pin(resource.id, pinnedViewId)

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ import type {
136136
QueuedSendHandoffSeed,
137137
} from '@/stores/mothership-queue/types'
138138
import type { ChatContext } from '@/stores/panel'
139+
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
139140
import { useTerminalConsoleStore } from '@/stores/terminal'
140141
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
141142
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -1637,6 +1638,8 @@ export function useChat(
16371638
setTransportIdle()
16381639
setResources([])
16391640
setActiveResourceId(null)
1641+
// Pending view pins belong to the chat whose stream issued them.
1642+
useTableViewPinStore.getState().reset()
16401643
undisplayableResourcesRef.current = []
16411644
pendingPersistResourceKeysRef.current.clear()
16421645
inFlightResourceAddsRef.current.clear()
@@ -2339,6 +2342,7 @@ export function useChat(
23392342
setTransportIdle()
23402343
setResources([])
23412344
setActiveResourceId(null)
2345+
useTableViewPinStore.getState().reset()
23422346
pendingPersistResourceKeysRef.current.clear()
23432347
inFlightResourceAddsRef.current.clear()
23442348
reorderNeededAfterFlushRef.current = false

apps/sim/lib/api/contracts/copilot.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,19 @@ export type RenameCopilotChatBody = z.input<typeof renameCopilotChatBodySchema>
9999

100100
const copilotResourceTypeSchema = z.enum(PERSISTED_RESOURCE_TYPES)
101101

102+
const copilotChatResourceItemSchema = z.object({
103+
type: copilotResourceTypeSchema,
104+
// Matches the bound the chat-send path enforces.
105+
id: requiredFieldSchema('resource.id cannot be empty'),
106+
title: z.string(),
107+
// Saved view a table tab is pinned to (type "table" only). One schema for
108+
// add and reorder, so a reorder round-trip can never strip the pin.
109+
viewId: z.string().min(1).optional(),
110+
})
111+
102112
export const addCopilotChatResourceBodySchema = z.object({
103113
chatId: z.string(),
104-
resource: z.object({
105-
type: copilotResourceTypeSchema,
106-
// Matches the bound the chat-send path enforces.
107-
id: requiredFieldSchema('resource.id cannot be empty'),
108-
title: z.string(),
109-
// Saved view a table tab is pinned to (type "table" only).
110-
viewId: z.string().min(1).optional(),
111-
}),
114+
resource: copilotChatResourceItemSchema,
112115
})
113116
export type AddCopilotChatResourceBody = z.input<typeof addCopilotChatResourceBodySchema>
114117

@@ -121,13 +124,7 @@ export type RemoveCopilotChatResourceBody = z.input<typeof removeCopilotChatReso
121124

122125
export const reorderCopilotChatResourcesBodySchema = z.object({
123126
chatId: z.string(),
124-
resources: z.array(
125-
z.object({
126-
type: copilotResourceTypeSchema,
127-
id: z.string(),
128-
title: z.string(),
129-
})
130-
),
127+
resources: z.array(copilotChatResourceItemSchema),
131128
})
132129
export type ReorderCopilotChatResourcesBody = z.input<typeof reorderCopilotChatResourcesBodySchema>
133130

apps/sim/lib/copilot/resources/types.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,27 @@ describe('mergeChatResource', () => {
183183
expect(mergeChatResource(pinnedB, stored)).toBe(pinnedB)
184184
})
185185
})
186+
187+
describe('mergeChatResource metadata', () => {
188+
it('takes the metadata a newcomer defines and keeps what it omits', () => {
189+
const placeholder = resource({ type: 'file', id: 'f1', title: 'File' })
190+
const upgraded = mergeChatResource(placeholder, {
191+
type: 'file',
192+
id: 'f1',
193+
title: 'notes.md',
194+
path: 'files/notes.md',
195+
})
196+
expect(upgraded).toEqual({ type: 'file', id: 'f1', title: 'notes.md', path: 'files/notes.md' })
197+
198+
// A later re-add without a path keeps the stored one.
199+
expect(mergeChatResource(upgraded, { type: 'file', id: 'f1', title: 'notes.md' })).toBe(
200+
upgraded
201+
)
202+
203+
const log = resource({ type: 'log', id: 'row-1', title: 'Run' })
204+
expect(
205+
mergeChatResource(log, { type: 'log', id: 'row-1', title: 'Run', executionId: 'exec-1' })
206+
.executionId
207+
).toBe('exec-1')
208+
})
209+
})

apps/sim/lib/copilot/resources/types.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -214,23 +214,34 @@ export const GENERIC_RESOURCE_TITLES = new Set<string>([
214214

215215
/**
216216
* Folds a re-added resource into the stored entry with the same type+id. The
217-
* stored title wins unless it was a placeholder. A table's saved-view pin is
218-
* replaced when the newcomer carries one — the tab reopens on the view the
219-
* agent touched last — and kept when it does not, so an unrelated row edit
220-
* never unpins the tab.
217+
* stored title wins unless it was a placeholder. Every other field the
218+
* newcomer defines replaces the stored one — a file's `path`, a log's
219+
* `executionId`, a table's saved-view pin (the tab reopens on the view the
220+
* agent touched last) — while a field the newcomer omits is kept, so an
221+
* unrelated row edit never unpins a table. Returns `prev` itself when nothing
222+
* changes, so callers can skip a no-op write.
221223
*/
222224
export function mergeChatResource(
223225
prev: MothershipResource | undefined,
224226
next: MothershipResource
225227
): MothershipResource {
226228
if (!prev) return next
227-
const title =
228-
GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(next.title)
229-
? next.title
230-
: prev.title
231-
const viewId = next.viewId ?? prev.viewId
232-
if (title === prev.title && viewId === prev.viewId) return prev
233-
return { ...prev, title, ...(viewId !== undefined ? { viewId } : {}) }
229+
const merged: MothershipResource = {
230+
...prev,
231+
...(next.path !== undefined ? { path: next.path } : {}),
232+
...(next.viewId !== undefined ? { viewId: next.viewId } : {}),
233+
...(next.executionId !== undefined ? { executionId: next.executionId } : {}),
234+
title:
235+
GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(next.title)
236+
? next.title
237+
: prev.title,
238+
}
239+
const unchanged =
240+
merged.title === prev.title &&
241+
merged.path === prev.path &&
242+
merged.viewId === prev.viewId &&
243+
merged.executionId === prev.executionId
244+
return unchanged ? prev : merged
234245
}
235246

236247
export const VFS_DIR_TO_RESOURCE: Record<string, MothershipResourceType> = {

apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({
2020
}))
2121

2222
import { createTableViewServerTool } from '@/lib/copilot/tools/server/table/create-table-view'
23+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
2324
import { createTableViewUseCase, listTableViewsUseCase } from '@/lib/table/application/views'
2425

2526
const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never
@@ -103,7 +104,7 @@ describe('create_table_view', () => {
103104
})
104105
})
105106

106-
it('numbers an unnamed view after the ones the table already has and passes isDefault through', async () => {
107+
it('leaves an omitted name to the service (numbered under the lock) and passes isDefault through', async () => {
107108
executeUseCase
108109
.mockResolvedValueOnce({ table, views: [{ id: 'view-0' }, { id: 'view-1' }] })
109110
.mockResolvedValueOnce({
@@ -112,22 +113,42 @@ describe('create_table_view', () => {
112113
})
113114

114115
const result = await createTableViewServerTool.execute(
115-
{ tableId: 'tbl-1', isDefault: true },
116+
{ tableId: 'tbl-1', name: ' ', isDefault: true },
116117
context
117118
)
118119

119120
expect(executeUseCase).toHaveBeenNthCalledWith(
120121
2,
121122
context,
122123
createTableViewUseCase,
123-
{ tableId: 'tbl-1', workspaceId: 'ws-1', name: 'View 3', config: {}, isDefault: true },
124+
{ tableId: 'tbl-1', workspaceId: 'ws-1', name: undefined, config: {}, isDefault: true },
124125
{ tableId: 'tbl-1' }
125126
)
126127
expect(result.success).toBe(true)
128+
expect(result.message).toContain('"View 3"')
127129
expect(result.message).toContain('as its default')
128130
expect(result.data?.view.isDefault).toBe(true)
129131
})
130132

133+
it("classifies an unknown column as the caller's mistake, before any write", async () => {
134+
executeUseCase.mockResolvedValueOnce({ table, views: [] })
135+
136+
const failure = await createTableViewServerTool
137+
.execute(
138+
{
139+
tableId: 'tbl-1',
140+
name: 'Urgent',
141+
config: { filter: { all: [{ field: 'priority', op: 'eq', value: 'high' }] } },
142+
},
143+
context
144+
)
145+
.catch((error: unknown) => error)
146+
147+
expect(asOrchestrationError(failure)?.code).toBe('validation')
148+
expect(asOrchestrationError(failure)?.message).toMatch(/Unknown column\(s\): priority/)
149+
expect(executeUseCase).toHaveBeenCalledTimes(1)
150+
})
151+
131152
it('refuses without a table id and without workspace context', async () => {
132153
expect(await createTableViewServerTool.execute({ tableId: ' ' }, context)).toEqual({
133154
success: false,

apps/sim/lib/copilot/tools/server/table/create-table-view.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ interface CreateTableViewArgs {
2020
/**
2121
* The main agent's direct path to a new saved view (the table subagent goes
2222
* through table_views). One list read supplies the columns for name→id
23-
* translation and the count behind the default name; the create then lands in
24-
* a single transaction, default flag included. The result names the table so
23+
* translation; the create then lands in a single locked transaction — default
24+
* flag and, when no name was given, the `View N` fallback included, so two
25+
* unnamed creates can never pick the same N. The result names the table so
2526
* resource extraction opens the panel pinned to the new view.
2627
*/
2728
export const createTableViewServerTool: BaseServerTool<CreateTableViewArgs, TableViewToolResult> = {
@@ -39,7 +40,7 @@ export const createTableViewServerTool: BaseServerTool<CreateTableViewArgs, Tabl
3940
{ tableId }
4041
)
4142
const columns = (listed.table.schema as TableSchema).columns
42-
const name = params.name?.trim() || `View ${listed.views.length + 1}`
43+
const name = params.name?.trim() || undefined
4344
const created = await executeCopilotTableUseCase(
4445
context,
4546
createTableViewUseCase,

0 commit comments

Comments
 (0)