feat(ai-chat): tell the chat which kind of app it is looking at (#11208)

* feat(ai-chat): tell the chat which kind of app it is looking at

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018r5ppn8YmHsM1MaAwY7xgB

* refactor(ai-chat): drop a stale comment and hoist the prefix local

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018r5ppn8YmHsM1MaAwY7xgB

* test(ai-chat): add a global eval for refusing to edit a drag-and-drop app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018r5ppn8YmHsM1MaAwY7xgB

* test(ai-chat): make the app-kind eval require editing the code app too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018r5ppn8YmHsM1MaAwY7xgB

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-18 14:19:06 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 6f9c4dc294
commit c8c06d8f79
7 changed files with 160 additions and 5 deletions
+8 -4
View File
@@ -49,12 +49,16 @@ export interface BenchmarkWorkspaceFlow {
export interface BenchmarkWorkspaceApp {
path: string
summary: string
/** Defaults to true. Set false for a drag-and-drop app, which the chat can list
* and read but has no tool to edit — its value is a grid, not files. */
rawApp?: boolean
value: {
files: Record<string, string>
runnables: Record<string, unknown>
files?: Record<string, string>
runnables?: Record<string, unknown>
data?: unknown
policy?: unknown
custom_path?: unknown
[key: string]: unknown
}
}
@@ -994,7 +998,7 @@ function buildBenchmarkListableApp(app: BenchmarkWorkspaceApp): ListableApp {
extra_perms: {},
edited_at: BENCHMARK_TIMESTAMP,
execution_mode: 'viewer',
raw_app: true
raw_app: app.rawApp ?? true
}
}
@@ -1012,7 +1016,7 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
execution_mode: 'viewer',
extra_perms: {},
custom_path: app.value.custom_path as string | undefined,
raw_app: true
raw_app: app.rawApp ?? true
}
}
+37
View File
@@ -2611,3 +2611,40 @@
judgeChecklist:
- runs the existing script rather than rewriting it
- passes the GitHub resource as the bare string $res:f/evals/global/github_main
- id: global-drag-and-drop-app-not-editable
prompt: |-
Add a refresh button to the ops console app, and the same to the sales board app.
initial: ai_evals/fixtures/frontend/global/initial/apps_code_and_drag_and_drop.json
runtime:
maxTurns: 12
validate:
# One request, two apps, only one of them editable: the code app must come back with a
# draft and the drag-and-drop one must not. Refusing both, or editing both, fails here —
# which is what makes this a test of the distinction rather than of caution.
draftCountExactly: 1
requiredDrafts:
- type: app
path: f/evals/global/ops_console
forbiddenDrafts:
- type: app
path: f/evals/global/sales_board
toolExpect:
# Deliberately not constraining write_app_file/patch_app_file by argument: an entry there
# fails when its tool was never called, so naming both would fail on whichever the model
# did not pick. The draft assertions above cover the same ground, tool-agnostically.
forbiddenToolsUsed:
- init_app
- deploy_workspace_item
- delete_app_file
- delete_app_runnable
assistantExpect:
# A refusal leaves no draft for the judge to read, so the explanation is checked here.
# Only the app kind: substring tests cannot see paraphrase, and every wording of "I can't
# edit it" defeats a fixed list.
requiredMentionsAnyOf:
- - drag-and-drop
- drag and drop
- low-code
- no-code
skipJudge: true
@@ -0,0 +1,30 @@
{
"user": {
"username": "admin",
"is_admin": true,
"folders": ["evals"],
"folders_read": ["evals"]
},
"workspace": {
"apps": [
{
"path": "f/evals/global/sales_board",
"summary": "Sales board",
"rawApp": false,
"value": {
"grid": []
}
},
{
"path": "f/evals/global/ops_console",
"summary": "Ops console",
"value": {
"files": {
"/App.tsx": "export default function App() {\n\treturn <div>Ops console</div>\n}\n"
},
"runnables": {}
}
}
]
}
}
@@ -2018,6 +2018,69 @@ describe('global AI tools', () => {
})
})
it('tells a code app from a drag-and-drop app', async () => {
vi.mocked(AppService.listApps).mockResolvedValueOnce([
{ path: 'f/apps/code', summary: 'Code app', raw_app: true },
{ path: 'f/apps/builder', summary: 'Builder app' }
] as any)
// An app draft is always a code app: the chat cannot address a
// drag-and-drop app's draft kind at all.
seedBackendDraft(
'raw_app',
'u/admin/draft_listed',
{ summary: 'Draft app' },
{ workspace: WORKSPACE }
)
const rows = JSON.parse(await callGlobalTool('list_workspace_items', { types: ['app'] }))
expect(rows.map((r: any) => [r.path, r.rawApp])).toEqual([
['f/apps/code', true],
['f/apps/builder', false],
['u/admin/draft_listed', true]
])
})
it('still says which kind of app it is when the app is read directly', async () => {
// The flag decides whether the app tools are offered at all, and the model
// reads an app before it edits one — a listing that knows is not enough.
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/builder',
summary: 'Builder app',
value: { grid: [] },
raw_app: false
} as any)
const read = JSON.parse(
await callGlobalTool('read_workspace_item', { type: 'app', path: 'f/apps/builder' })
)
expect(read.rawApp).toBe(false)
})
it('finds a staged app under the folder it was filed in, not its generated path', async () => {
// A never-deployed app the editor created lives at a generated path, so the
// folder the user filed it under exists only as its staged name. The server
// drops draft-only rows under any narrowing filter, leaving this pass the one
// that can answer a folder-scoped question about it.
seedBackendDraft(
'raw_app',
'u/admin/draft_7f21c9',
{ summary: '', draft_path: 'f/team/invoice_tracker' },
{ workspace: WORKSPACE }
)
const matched = JSON.parse(
await callGlobalTool('list_workspace_items', { types: ['app'], path_prefix: 'f/team/' })
)
expect(matched).toHaveLength(1)
expect(matched[0].draftPath).toBe('f/team/invoice_tracker')
const other = JSON.parse(
await callGlobalTool('list_workspace_items', { types: ['app'], path_prefix: 'f/other/' })
)
expect(other).toEqual([])
})
it('applies path_prefix to drafts before enforcing the result limit', async () => {
await callGlobalTool('write_script', {
path: 'f/other/outside',
@@ -1421,6 +1421,7 @@ Flows:
- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.
Raw apps:
- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app, which you can list and read but not edit or deploy. Check it before offering to change an app.
- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.
- Use write_app_file, patch_app_file, and delete_app_file for frontend files.
- Use write_app_runnable and delete_app_runnable for backend runnables.
@@ -1526,6 +1527,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
path: item.path,
summary: item.summary,
value: summarizeAppValue(item.value as AppDraftValue),
rawApp: item.rawApp,
isDraft: item.isDraft
}
}
@@ -1752,6 +1754,9 @@ function appToItem(app: ListableApp | AppWithLastVersion, includeValue: boolean)
path: app.path,
summary: app.summary,
value: includeValue ? ((app as AppWithLastVersion).value as AppDraftValue) : undefined,
// The server omits this flag rather than sending false, so its absence in the
// response is a known false — not a value this listing failed to fetch.
rawApp: app.raw_app ?? false,
isDraft: false
}
}
@@ -2038,6 +2043,7 @@ async function readWorkspaceItem(
path: app.path,
summary: value.summary,
value: metadata as unknown as AppDraftValue,
rawApp: app.raw_app,
isDraft: false
}
}
@@ -3408,9 +3414,16 @@ export const globalTools: Tool<{}>[] = [
// (it filters before the cap; query filters after).
if ((parsed.page ?? 1) === 1) {
const draftCountByType = new Map<string, number>()
const prefix = parsed.path_prefix
for (const draft of await listGlobalDrafts(workspace)) {
if (!types.includes(draft.type)) continue
if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue
// A draft's staged name is often not where it is stored: the editor parks
// a new script, flow or app at a generated `draft_<uuid>` path, and a
// rename stages the new name over the old path. The server drops
// draft-only rows under any narrowing filter, leaving this pass their
// only source, so either name has to satisfy the prefix.
if (prefix && !draft.path.startsWith(prefix) && !draft.draftPath?.startsWith(prefix))
continue
const count = draftCountByType.get(draft.type) ?? 0
if (count >= limit) continue
draftCountByType.set(draft.type, count + 1)
@@ -176,6 +176,9 @@ function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceI
summary: value.summary,
parentVersionId: value.parent_version,
value,
// The chat only ever addresses the `raw_app` draft kind (see itemKindFor),
// so every app draft it can see is a code app.
rawApp: true,
isDraft: true
}
}
@@ -541,6 +544,7 @@ function backendDraftRowToWorkspaceItem(
value: undefined,
isDraft: true,
triggerKind,
rawApp: row.kind === 'raw_app' ? true : undefined,
...(isLiveDraft ? { isLiveDraft: true } : {})
}
}
@@ -139,6 +139,10 @@ export type WorkspaceItem = {
* without it a reader cannot tell a secret from a plain variable, since the
* value is always redacted. */
isSecret?: boolean
/** Apps only. True for a code app, false for one built in the drag-and-drop
* editor. The two are edited by disjoint tool sets, so the distinction has to
* reach the model before it picks one. */
rawApp?: boolean
isDraft: boolean
isLiveDraft?: boolean
}