Add user JSON acceptance configuration and state visibility

This commit is contained in:
mateaix 2026-09-14 21:09:21 +08:00
parent 868e2dbfb1
commit 971aa536a8
10 changed files with 310 additions and 0 deletions

View File

@ -222,6 +222,7 @@ public class GoalManagementTool {
out.put("evalLlmCallsUsed", goal.getEvalLlmCallsUsed());
out.put("totalLlmCallsUsed", goal.totalLlmCallsUsed());
out.put("llmCallBudget", goal.getLlmCallBudget());
out.put("jsonAcceptanceRequired", goal.isJsonAcceptanceRequired());
out.put("completionScore", goal.getCompletionScore());
out.put("progressSummary", goal.getProgressSummary());
out.put("autoFollowupEnabled", goal.getAutoFollowupEnabled());

View File

@ -195,10 +195,12 @@ class GoalManagementToolTest {
@Test
void getGoalStatus_active_carriesProgressSummary() {
GoalEntity g = goal(GoalStatus.ACTIVE);
g.setJsonAcceptanceRequired(true);
g.setProgressSummary("missing DNS");
g.setCompletionScore(0.62);
when(goalService.findActiveByConversation("conv-1")).thenReturn(g);
String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice"));
assertTrue(result.contains("\"jsonAcceptanceRequired\":true"));
assertTrue(result.contains("\"goalId\":\"123\""));
assertTrue(result.contains("\"completionScore\":0.62"));
assertTrue(result.contains("missing DNS"));

View File

@ -0,0 +1,13 @@
import { afterEach, expect, it, vi } from 'vitest'
import { http } from '@/api/index'
import { goalJsonAcceptanceApi } from '@/api/goalJsonAcceptance'
afterEach(() => vi.restoreAllMocks())
it('keeps goal IDs and optimistic revisions as exact strings', () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({} as never)
const put = vi.spyOn(http, 'put').mockResolvedValue({} as never)
const goal = '9223372036854775801'
const data = { expectedRevision: '9223372036854775802', artifactSlot: 'report', requiredFields: ['summary'] }
goalJsonAcceptanceApi.get(goal); goalJsonAcceptanceApi.configure(goal, 'report-fields', data)
expect(get).toHaveBeenCalledWith(`/goals/${goal}/json-acceptance`)
expect(put).toHaveBeenCalledWith(`/goals/${goal}/json-acceptance/requirements/report-fields`, data)
})

View File

@ -0,0 +1,16 @@
import { http } from './index'
export interface GoalJsonRequirement {
criterionKey: string
artifactSlot: string
revision: string
requiredFields: string[]
configuredBy: string
}
export interface GoalJsonAcceptanceView { required: boolean; requirements: GoalJsonRequirement[] }
export interface ConfigureJsonRequirement { expectedRevision: string; artifactSlot: string; requiredFields: string[] }
export const goalJsonAcceptanceApi = {
get: (goalId: string) => http.get<never, { data: GoalJsonAcceptanceView }>(`/goals/${encodeURIComponent(goalId)}/json-acceptance`),
configure: (goalId: string, key: string, data: ConfigureJsonRequirement) =>
http.put<never, { data: GoalJsonRequirement }>(`/goals/${encodeURIComponent(goalId)}/json-acceptance/requirements/${encodeURIComponent(key)}`, data),
}

View File

@ -1786,6 +1786,7 @@ export interface GoalCriterion {
}
export interface Goal {
jsonAcceptanceRequired?: boolean
id: string
conversationId: string
agentId: string

View File

@ -42,6 +42,7 @@
</ul>
<p v-if="goal.progressSummary" class="gp-goal__gap">{{ goal.progressSummary }}</p>
<GoalJsonAcceptancePanel :goal-id="goal.id" :status="goal.status" />
<ExecutionEvidenceList v-if="goal.conversationId" :conversation-id="goal.conversationId" :goal-id="goal.id" />
</div>
</div>
@ -56,6 +57,7 @@ import { watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Goal } from '@/api'
import ExecutionEvidenceList from '@/components/execution/ExecutionEvidenceList.vue'
import GoalJsonAcceptancePanel from '@/components/goal/GoalJsonAcceptancePanel.vue'
const props = defineProps<{
open: boolean

View File

@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { goalJsonAcceptanceApi, type GoalJsonAcceptanceView, type GoalJsonRequirement } from '@/api/goalJsonAcceptance'
const props = defineProps<{ goalId: string; status: string }>()
const { t } = useI18n()
const expanded = ref(false)
const view = ref<GoalJsonAcceptanceView | null>(null)
const loading = ref(false)
const saving = ref(false)
const conflict = ref(false)
const error = ref('')
const key = ref('')
const slot = ref('')
const fields = ref('')
const revision = ref('0')
let generation = 0
const editable = computed(() => ['active', 'paused'].includes(props.status))
const busy = computed(() => loading.value || saving.value)
function resetForm() { key.value = ''; slot.value = ''; fields.value = ''; revision.value = '0'; conflict.value = false }
function edit(requirement: GoalJsonRequirement) {
if (busy.value) return
key.value = requirement.criterionKey
slot.value = requirement.artifactSlot
fields.value = requirement.requiredFields.join('\n')
revision.value = requirement.revision
conflict.value = false
error.value = ''
}
function failureCode(failure: unknown) {
const e = failure as { code?: number; response?: { status?: number } }
return e.response?.status ?? e.code
}
function showFailure(failure: unknown) {
const code = failureCode(failure)
if (code === 401 || code === 403 || code === 404) {
view.value = null
resetForm()
error.value = 'goalJsonAcceptance.accessError'
} else if (code === 409) {
conflict.value = true
error.value = 'goalJsonAcceptance.conflict'
} else error.value = code === 400 ? 'goalJsonAcceptance.inputError' : 'goalJsonAcceptance.loadError'
}
async function load() {
if (busy.value || !props.goalId) return
const request = ++generation
loading.value = true
error.value = ''
view.value = null
resetForm()
try {
const { data } = await goalJsonAcceptanceApi.get(props.goalId)
if (request === generation) view.value = data
} catch (failure) {
if (request === generation) showFailure(failure)
} finally { if (request === generation) loading.value = false }
}
async function save() {
if (busy.value || conflict.value || !view.value || !editable.value) return
const required = fields.value.split(/\r?\n/).filter(field => field.length > 0)
const validKey = (value: string) => /^[a-z][a-z0-9_-]{0,63}$/.test(value)
if (!validKey(key.value) || !validKey(slot.value) || required.length < 1 || required.length > 16
|| new Set(required).size !== required.length || required.some(field => !field.trim() || field.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(field))) {
error.value = 'goalJsonAcceptance.inputError'
return
}
const request = generation
saving.value = true
error.value = ''
try {
const { data } = await goalJsonAcceptanceApi.configure(props.goalId, key.value, {
expectedRevision: revision.value, artifactSlot: slot.value, requiredFields: required,
})
if (request !== generation || !view.value) return
view.value = { required: true, requirements: [...view.value.requirements.filter(r => r.criterionKey !== data.criterionKey), data]
.sort((a, b) => a.criterionKey.localeCompare(b.criterionKey)) }
resetForm()
} catch (failure) {
if (request === generation) showFailure(failure)
} finally { if (request === generation) saving.value = false }
}
function toggle() { expanded.value = !expanded.value; if (expanded.value && !view.value) void load() }
watch(() => props.goalId, () => {
generation++
view.value = null
loading.value = false
saving.value = false
error.value = ''
resetForm()
if (expanded.value) void load()
})
onBeforeUnmount(() => { generation++ })
</script>
<template>
<section class="json-acceptance">
<button type="button" data-json-acceptance-toggle :aria-expanded="expanded" @click="toggle">{{ t('goalJsonAcceptance.title') }}</button>
<div v-if="expanded" class="json-acceptance__body" :aria-busy="busy">
<p>{{ t('goalJsonAcceptance.scope') }}</p>
<button type="button" data-json-acceptance-refresh :disabled="busy" @click="load">{{ t('goalJsonAcceptance.refresh') }}</button>
<p v-if="error" role="alert">{{ t(error) }}</p>
<p v-if="loading" role="status">{{ t('common.loading') }}</p>
<template v-if="view">
<p data-json-acceptance-mode>{{ t(view.required ? 'goalJsonAcceptance.required' : 'goalJsonAcceptance.notSelected') }}</p>
<ul v-if="view.requirements.length">
<li v-for="requirement in view.requirements" :key="requirement.criterionKey" data-json-requirement>
<strong>{{ requirement.criterionKey }}</strong> · {{ requirement.artifactSlot }}
<p>{{ requirement.requiredFields.join(', ') }}</p>
<button v-if="editable" type="button" :disabled="busy" data-json-requirement-edit @click="edit(requirement)">{{ t('goalJsonAcceptance.edit') }}</button>
</li>
</ul>
<form v-if="editable" @submit.prevent="save">
<p>{{ t('goalJsonAcceptance.selectionNotice') }}</p>
<label>{{ t('goalJsonAcceptance.key') }}<input v-model="key" data-json-requirement-key required maxlength="64" :disabled="busy || revision !== '0'" placeholder="report-fields" /></label>
<label>{{ t('goalJsonAcceptance.slot') }}<input v-model="slot" data-json-requirement-slot required maxlength="64" :disabled="busy" placeholder="report" /></label>
<label>{{ t('goalJsonAcceptance.fields') }}<textarea v-model="fields" data-json-requirement-fields required rows="3" maxlength="2064" :disabled="busy" :placeholder="t('goalJsonAcceptance.fieldsPlaceholder')" /></label>
<p>{{ t('goalJsonAcceptance.inputHelp') }}</p>
<div class="json-acceptance__actions">
<button type="submit" data-json-requirement-save :disabled="busy || conflict || (revision === '0' && view.requirements.length >= 8)">{{ t(saving ? 'common.loading' : 'goalJsonAcceptance.save') }}</button>
<button v-if="revision !== '0'" type="button" :disabled="busy" @click="resetForm">{{ t('goalJsonAcceptance.newRequirement') }}</button>
</div>
</form>
</template>
</div>
</section>
</template>
<style scoped>
.json-acceptance { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--mc-border-light); font-size: 12px; color: var(--mc-text-secondary); }
.json-acceptance__body, form, label { display: grid; gap: 8px; }
p { margin: 6px 0; line-height: 1.6; overflow-wrap: anywhere; }
ul { padding-left: 18px; display: grid; gap: 8px; }
button, input, textarea { border: 1px solid var(--mc-border-light); border-radius: 6px; background: transparent; color: var(--mc-text-primary); font: inherit; padding: 6px 9px; }
button { cursor: pointer; justify-self: start; }
button:disabled, input:disabled, textarea:disabled { opacity: .55; }
input, textarea { width: 100%; box-sizing: border-box; }
textarea { resize: vertical; }
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--mc-primary); outline-offset: 2px; }
.json-acceptance__actions { display: flex; gap: 8px; flex-wrap: wrap; }
[role="alert"] { color: var(--mc-danger, #b53535); }
</style>

View File

@ -0,0 +1,91 @@
import { createApp, h, nextTick, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import GoalJsonAcceptancePanel from '../GoalJsonAcceptancePanel.vue'
import { goalJsonAcceptanceApi } from '@/api/goalJsonAcceptance'
import en from '@/i18n/locales/en-US'
vi.mock('@/api/goalJsonAcceptance', () => ({ goalJsonAcceptanceApi: { get: vi.fn(), configure: vi.fn() } }))
const apps: ReturnType<typeof createApp>[] = []
const id = '9223372036854775801'
const requirement = (revision = '1') => ({ criterionKey: 'report-fields', artifactSlot: 'report', revision, requiredFields: ['summary'], configuredBy: 'alice' })
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
function mount(status = 'active') {
const props = reactive({ goalId: id, status })
const host = document.createElement('div'); document.body.append(host)
const app = createApp({ render: () => h(GoalJsonAcceptancePanel, props) })
app.use(createI18n({ legacy: false, locale: 'en', messages: { en } })); app.mount(host); apps.push(app)
return { host, props }
}
async function open(host: HTMLElement) { host.querySelector<HTMLButtonElement>('[data-json-acceptance-toggle]')!.click(); await flush() }
async function fill(host: HTMLElement, selector: string, value: string) {
const field = host.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)!
field.value = value; field.dispatchEvent(new Event('input')); await flush()
}
async function submit(host: HTMLElement) { host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush() }
afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = ''; vi.resetAllMocks() })
describe('user JSON acceptance requirements', () => {
it('loads without writing and requires an explicit save to opt in', async () => {
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: false, requirements: [] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockResolvedValue({ data: requirement() })
const { host } = mount(); expect(goalJsonAcceptanceApi.get).not.toHaveBeenCalled(); await open(host)
expect(goalJsonAcceptanceApi.configure).not.toHaveBeenCalled()
expect(host.textContent).toContain('cannot be switched off')
await fill(host, '[data-json-requirement-key]', 'report-fields')
await fill(host, '[data-json-requirement-slot]', 'report')
await fill(host, '[data-json-requirement-fields]', 'summary\nsources')
expect(goalJsonAcceptanceApi.configure).not.toHaveBeenCalled(); await submit(host)
expect(goalJsonAcceptanceApi.configure).toHaveBeenCalledWith(id, 'report-fields', { expectedRevision: '0', artifactSlot: 'report', requiredFields: ['summary', 'sources'] })
expect(host.querySelector('[data-json-acceptance-mode]')?.textContent).toContain('required before')
})
it('preserves opaque revisions and refuses to resubmit a stale edit before reload', async () => {
const revision = '9223372036854775802'
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, requirements: [requirement(revision)] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockRejectedValue({ code: 409 })
const { host } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush()
await fill(host, '[data-json-requirement-fields]', 'appendix'); await submit(host)
expect(goalJsonAcceptanceApi.configure).toHaveBeenCalledWith(id, 'report-fields', { expectedRevision: revision, artifactSlot: 'report', requiredFields: ['appendix'] })
expect(host.querySelector('[role="alert"]')?.textContent).toContain('Reload before saving')
expect(host.querySelector<HTMLButtonElement>('[data-json-requirement-save]')!.disabled).toBe(true)
await submit(host); expect(goalJsonAcceptanceApi.configure).toHaveBeenCalledTimes(1)
})
it('clears old requirements and drafts after access is revoked', async () => {
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, requirements: [requirement()] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockRejectedValue({ code: 403 })
const { host } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush(); await submit(host)
expect(host.querySelectorAll('[data-json-requirement]')).toHaveLength(0)
expect(host.querySelector('form')).toBeNull()
expect(host.querySelector('[role="alert"]')?.textContent).toContain('Access')
})
it('does not show an old goal read response after selection changes', async () => {
let resolve!: (value: unknown) => void
vi.mocked(goalJsonAcceptanceApi.get).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never)
.mockResolvedValueOnce({ data: { required: false, requirements: [] } })
const { host, props } = mount(); await open(host); props.goalId = 'new'; await flush()
resolve({ data: { required: true, requirements: [requirement()] } }); await flush()
expect(host.querySelectorAll('[data-json-requirement]')).toHaveLength(0)
expect(host.querySelector('[data-json-acceptance-mode]')?.textContent).toContain('has not selected')
})
it('does not apply a delayed save to a different goal', async () => {
let resolve!: (value: unknown) => void
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValueOnce({ data: { required: true, requirements: [requirement()] } })
.mockResolvedValueOnce({ data: { required: false, requirements: [] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never)
const { host, props } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush(); await submit(host)
props.goalId = 'new'; await flush(); resolve({ data: requirement('2') }); await flush()
expect(host.querySelectorAll('[data-json-requirement]')).toHaveLength(0)
expect(host.querySelector('[data-json-acceptance-mode]')?.textContent).toContain('has not selected')
})
it('does not submit duplicate fields and presents terminal goals as read-only', async () => {
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, requirements: [requirement()] } })
const { host, props } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush()
await fill(host, '[data-json-requirement-fields]', 'summary\nsummary'); await submit(host)
expect(goalJsonAcceptanceApi.configure).not.toHaveBeenCalled()
expect(host.querySelector('[role="alert"]')?.textContent).toContain('unique')
props.status = 'completed'; await flush(); expect(host.querySelector('form')).toBeNull()
expect(host.querySelector('[data-json-requirement-edit]')).toBeNull()
})
})

View File

@ -1,4 +1,24 @@
export default {
goalJsonAcceptance: {
"title": "Managed JSON acceptance",
"scope": "Require the listed top-level fields in a platform-managed JSON object, with values other than null (false, zero and empty strings are allowed). Text claims and ordinary file checks cannot satisfy this requirement.",
"refresh": "Reload requirements",
"required": "JSON acceptance is required before this goal can complete.",
"notSelected": "This goal has not selected managed JSON acceptance.",
"selectionNotice": "Saving enables a required check for this goal. It cannot be switched off; you can revise its requirements.",
"key": "Requirement name",
"slot": "Managed artifact name",
"fields": "Required JSON fields",
"fieldsPlaceholder": "One top-level field per line",
"inputHelp": "Names: lowercase letters, digits, underscores or hyphens, starting with a letter. Up to 8 requirements; 116 unique fields per requirement.",
"save": "Save required check",
"edit": "Edit requirement",
"newRequirement": "New requirement",
"accessError": "Access to this goal is unavailable. Reload after checking your permissions.",
"conflict": "The goal or requirement changed. Reload before saving again.",
"inputError": "Check the names and enter 116 unique, nonempty field names of up to 128 characters each.",
"loadError": "Could not load or save the requirements. Try again."
},
executionEvidence: {
jsonCheck: {
label: 'Required JSON fields', placeholder: 'One top-level field per line', run: 'Check JSON file',

View File

@ -1,4 +1,24 @@
export default {
goalJsonAcceptance: {
"title": "受管 JSON 验收",
"scope": "要求平台受管 JSON 对象存在指定顶层字段且值不为 null允许 false、0 和空字符串)。文本声明和普通文件诊断不能满足这项要求。",
"refresh": "重新读取要求",
"required": "此目标必须通过 JSON 验收才能完成。",
"notSelected": "此目标尚未选择受管 JSON 验收。",
"selectionNotice": "保存会为此目标启用必须通过的检查,之后不能关闭,但可以修改具体要求。",
"key": "要求名称",
"slot": "受管产物名称",
"fields": "必需 JSON 字段",
"fieldsPlaceholder": "每行一个顶层字段",
"inputHelp": "名称以小写字母开头仅用小写字母、数字、下划线或短横线。最多8项要求每项116个唯一字段。",
"save": "保存必需检查",
"edit": "编辑要求",
"newRequirement": "新增要求",
"accessError": "无法访问此目标,请核对权限后重新读取。",
"conflict": "目标或要求已经变化,请重新读取后再保存。",
"inputError": "请核对名称填写116个不重复的非空字段名每个不超过128字符。",
"loadError": "无法读取或保存要求,请重试。"
},
executionEvidence: {
jsonCheck: {
label: 'JSON 必需字段', placeholder: '每行一个顶层字段名', run: '检查 JSON 文件',