fix(goal): retain acceptance access for paused and terminal goals

This commit is contained in:
mateaix 2026-09-15 00:08:08 +08:00
parent dec4c509c4
commit 54e7b9b9e0
14 changed files with 229 additions and 18 deletions

View File

@ -82,6 +82,15 @@ public class GoalController {
return R.ok(goalService.toResponse(goalService.findActiveByConversation(conversationId)));
}
@Operation(summary = "Read this conversation's goal history, including paused and terminal goals")
@GetMapping("/by-conversation/{conversationId}/history")
public R<List<GoalResponse>> history(@PathVariable String conversationId,
@RequestParam(required = false) Long beforeId,
@RequestParam(defaultValue = "20") int limit, Authentication auth) {
requireOwner(conversationId, currentUsername(auth));
return R.ok(goalService.toResponseList(goalService.listByConversation(conversationId, beforeId, limit)));
}
@Operation(summary = "Get goal detail by id")
@GetMapping("/{id}")
public R<GoalResponse> get(@PathVariable Long id, Authentication auth) {

View File

@ -41,6 +41,9 @@ public interface GoalService {
/** Paged list filtered by status / owner. */
List<GoalEntity> list(String status, String username, int limit);
/** Conversation-scoped history, newest id first, with an exclusive cursor. */
List<GoalEntity> listByConversation(String conversationId, Long beforeId, int limit);
/** Sparse update. Throws if any terminal-state goal is targeted. */
GoalEntity update(Long id, GoalUpdateRequest req, String username);

View File

@ -214,6 +214,16 @@ public class GoalServiceImpl implements GoalService {
.last("LIMIT 1"));
}
@Override
public List<GoalEntity> listByConversation(String conversationId, Long beforeId, int limit) {
if (conversationId == null || conversationId.isBlank()) return List.of();
return goalMapper.selectList(new LambdaQueryWrapper<GoalEntity>()
.eq(GoalEntity::getConversationId, conversationId)
.lt(beforeId != null, GoalEntity::getId, beforeId)
.orderByDesc(GoalEntity::getId)
.last("LIMIT " + Math.max(1, Math.min(50, limit))));
}
@Override
public List<GoalEntity> list(String status, String username, int limit) {
LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>()

View File

@ -4,6 +4,8 @@ Open a conversation with an existing Goal, click the Goals button in its header,
User configuration, independent managed versions, binding checks and the shared completion gate are connected. Every current requirement needs a matching valid binding before a selected goal can complete under its existing completion rules. Automatic evaluation, explicit completeGoal and retries share that gate. Unselected goals retain existing behavior.
The conversation Goals panel includes paused and terminal goals, loading 20 at a time with an option to load older records. Paused goals still allow requirement edits, publication and checks; terminal goals only expose existing requirements and content. Closing the panel, switching conversations or leaving the page clears its contents, and a failed refresh clears the old list. Reading history does not resume execution.
## Managed version API
Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conversation-owner or administrator permission is required. Preserve IDs, revisions and generations as strings in clients.

View File

@ -4,6 +4,8 @@
当前已接通用户配置、独立受管版本、绑定检查及共享完成检查。选中模式后,所有当前要求必须具有匹配的有效绑定,才可在既有完成规则满足时完成;自动评估、显式 completeGoal 和重试均使用同一完成检查。未选中的 Goal 保持既有行为。
当前会话的“目标”面板也保留暂停、已完成等历史目标每次读取20项可加载更早记录。暂停目标仍可修订要求、发布和检查终态目标只能查看要求与已有正文。关闭面板、切换会话或离开页面后清除面板内容刷新失败时清除旧列表。读取历史不会恢复目标运行。
## 受管版本接口
接口前缀 `/api/v1/goals/{goalId}/json-acceptance`需要启用账户及对话所有者或管理员权限。ID、revision 和 generation 在响应中使用字符串,客户端应原样保留。

View File

@ -71,6 +71,29 @@ class GoalJsonAcceptanceIntegrationTest {
return new GoalJsonAcceptanceService.ConfigureRequest(revision, "report", List.of(fields));
}
@Test void conversationHistoryPreservesPausedAndCompletedGoalsWithExclusivePaging() {
GoalEntity paused = goal(false);
acceptance.configure(paused.getId(), "r", request(0, "summary"), alice);
goals.pause(paused.getId(), alice);
GoalCreateRequest next = new GoalCreateRequest();
next.setConversationId(paused.getConversationId()); next.setWorkspaceId(1L); next.setAgentId(1L);
next.setTitle("Next report"); next.setDescription("History fixture"); next.setPersistentExecution(false);
GoalEntity completed = goals.create(next, alice);
goals.markCompleted(completed.getId(), null);
GoalEntity deleted = goals.create(next, alice);
jdbc.update("UPDATE mate_agent_goal SET deleted=1 WHERE id=?", deleted.getId());
goal(false); // A newer goal in another conversation must not enter this page.
var first = goals.listByConversation(paused.getConversationId(), null, 1);
assertEquals(List.of(completed.getId()), first.stream().map(GoalEntity::getId).toList());
assertEquals(GoalStatus.COMPLETED, first.getFirst().getStatus());
var second = goals.listByConversation(paused.getConversationId(), completed.getId(), 1);
assertEquals(List.of(paused.getId()), second.stream().map(GoalEntity::getId).toList());
assertEquals(GoalStatus.PAUSED, second.getFirst().getStatus());
assertTrue(second.getFirst().isJsonAcceptanceRequired());
assertTrue(goals.listByConversation(paused.getConversationId(), paused.getId(), 20).isEmpty());
assertNull(goals.findActiveByConversation(paused.getConversationId()), "History must not revive an inactive goal");
}
@Test void ownerCanPersistAndReviseRequirementsWithoutAcceptingAStaleEdit() {
GoalEntity goal = goal(false);
assertFalse(acceptance.get(goal.getId(), alice).required());

View File

@ -2,6 +2,8 @@ package vip.mate.goal.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.mockito.ArgumentMatchers.anyInt;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ -162,6 +164,25 @@ class GoalControllerTest {
// ==================== find / get ====================
@Test
void historyRequiresConversationOwnershipBeforeReadingAnyRows() {
when(conversationService.isConversationOwner("other", "alice")).thenReturn(false);
assertEquals(403, assertThrows(MateClawException.class,
() -> controller.history("other", null, 20, auth)).getCode());
verify(goalService, never()).listByConversation(anyString(), any(), anyInt());
}
@Test
void historyKeepsTheExclusiveLongCursorAndMapsEveryStatus() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
long cursor = 9223372036854775801L;
var rows = List.of(goal(2L, "conv-1", GoalStatus.COMPLETED), goal(1L, "conv-1", GoalStatus.PAUSED));
var responses = List.of(resp(2L, GoalStatus.COMPLETED), resp(1L, GoalStatus.PAUSED));
when(goalService.listByConversation("conv-1", cursor, 20)).thenReturn(rows);
when(goalService.toResponseList(rows)).thenReturn(responses);
assertEquals(responses, controller.history("conv-1", cursor, 20, auth).getData());
}
@Test
void findActive_returnsNull_whenNoActiveGoal() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);

View File

@ -1844,6 +1844,9 @@ export const goalApi = {
findActive: (conversationId: string) =>
http.get<Goal | null>(`/goals/by-conversation/${encId(conversationId)}`),
history: (conversationId: string, beforeId?: string) =>
http.get<Goal[]>(`/goals/by-conversation/${encId(conversationId)}/history`, { params: { beforeId, limit: 20 } }),
get: (id: string) => http.get<Goal>(`/goals/${id}`),
events: (id: string, limit = 100) =>

View File

@ -13,15 +13,18 @@
</button>
<div class="gp-head">
<h2 class="gp-head__title">{{ t('plans.activeGoals') }}</h2>
<h2 class="gp-head__title">{{ title || t('plans.activeGoals') }}</h2>
</div>
<div v-if="loading" class="pd-loading">{{ t('common.loading') }}</div>
<button v-if="showRefresh" type="button" :disabled="loading" @click="$emit('refresh')">{{ t('common.refresh') }}</button>
<p v-if="error" role="alert">{{ error }}</p>
<div v-if="loading && !goals.length" class="pd-loading">{{ t('common.loading') }}</div>
<el-empty v-else-if="!goals.length" :description="t('plans.noGoals')" />
<div v-else class="gp-list">
<div v-for="goal in goals" :key="goal.id" class="gp-goal">
<div class="gp-goal__title">{{ cleanGoal(goal.title) }}</div>
<p v-if="showRefresh">{{ t('goalJsonAcceptance.historyStatus.' + goal.status) }}</p>
<p v-if="showDesc(goal)" class="gp-goal__desc">{{ cleanGoal(goal.description) }}</p>
<div class="gp-goal__score" v-if="goal.completionScore != null">
@ -46,6 +49,7 @@
<ExecutionEvidenceList v-if="goal.conversationId" :conversation-id="goal.conversationId" :goal-id="goal.id" />
</div>
</div>
<button v-if="hasMore" type="button" :disabled="loading" @click="$emit('load-more')">{{ t('goalJsonAcceptance.historyLoadMore') }}</button>
</aside>
</div>
</Transition>
@ -63,9 +67,13 @@ const props = defineProps<{
open: boolean
goals: Goal[]
loading: boolean
title?: string
error?: string
hasMore?: boolean
showRefresh?: boolean
}>()
const emit = defineEmits<{ close: [] }>()
const emit = defineEmits<{ close: []; refresh: []; 'load-more': [] }>()
const { t } = useI18n()

View File

@ -0,0 +1,69 @@
<template>
<button class="conversation-goals-button" type="button" :title="t('plans.goals')"
:aria-label="t('plans.goals')" :aria-expanded="open" @click="show">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
</button>
<GoalsPanel v-if="open" :open="open" :goals="goals" :loading="loading"
:title="t('plans.goals')" :error="error" :has-more="hasMore" :show-refresh="true"
@close="close" @refresh="load(false)" @load-more="load(true)" />
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, onDeactivated } from 'vue'
import { useI18n } from 'vue-i18n'
import { goalApi, type Goal } from '@/api'
import GoalsPanel from '@/components/agents/GoalsPanel.vue'
const props = defineProps<{ conversationId: string }>()
const { t } = useI18n()
const open = ref(false)
const loading = ref(false)
const goals = ref<Goal[]>([])
const error = ref('')
const hasMore = ref(false)
let request = 0
function close() {
request++
open.value = false
loading.value = false
goals.value = []
error.value = ''
hasMore.value = false
}
async function show() {
open.value = true
await load(false)
}
async function load(append: boolean) {
if (loading.value || !props.conversationId) return
const cursor = append ? goals.value.at(-1)?.id : undefined
const token = ++request
const conversation = props.conversationId
loading.value = true
error.value = ''
if (!append) goals.value = []
try {
const response: any = await goalApi.history(conversation, cursor)
if (token !== request || conversation !== props.conversationId || !open.value) return
const page: Goal[] = response?.data ?? []
goals.value = append ? [...goals.value, ...page] : page
hasMore.value = page.length === 20
} catch {
if (token !== request || conversation !== props.conversationId || !open.value) return
goals.value = []
hasMore.value = false
error.value = t('goalJsonAcceptance.historyLoadFailed')
} finally {
if (token === request) loading.value = false
}
}
watch(() => props.conversationId, close)
onBeforeUnmount(close)
onDeactivated(close)
</script>
<style scoped>
.conversation-goals-button { width:30px; height:30px; border:1px solid var(--mc-border); background:var(--mc-panel-raised); border-radius:10px; cursor:pointer; display:flex; align-items:center; justify-content:center; color:var(--mc-text-secondary); }
.conversation-goals-button:hover { border-color:var(--mc-danger); color:var(--mc-danger); }
</style>

View File

@ -0,0 +1,67 @@
import { createApp, h, nextTick, reactive, ref, KeepAlive } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ConversationGoalsControl from '../ConversationGoalsControl.vue'
import { goalApi } from '@/api'
import en from '@/i18n/locales/en-US'
vi.mock('@/api', () => ({ goalApi: { history: vi.fn() } }))
vi.mock('@/components/agents/GoalsPanel.vue', () => ({ default: {
props: ['goals', 'loading', 'error', 'hasMore'], emits: ['close', 'refresh', 'load-more'],
template: '<section role="dialog"><p v-for="goal in goals" :key="goal.id">{{goal.id}} {{goal.status}}</p><span role="alert">{{error}}</span><button @click="$emit(\'close\')">Close</button><button @click="$emit(\'refresh\')">Refresh</button><button v-if="hasMore" @click="$emit(\'load-more\')">Older</button></section>',
} }))
const apps: ReturnType<typeof createApp>[] = []
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
function mount() {
const props = reactive({ conversationId: 'owned-conversation' })
const active = ref(true)
const host = document.createElement('div'); document.body.append(host)
const app = createApp({ render: () => h(KeepAlive, null, () => active.value ? h(ConversationGoalsControl, props) : h('div')) })
app.use(createI18n({ legacy: false, locale: 'en', messages: { en } })); app.mount(host); apps.push(app)
return { host, props, active }
}
async function click(host: HTMLElement, text: string) {
Array.from(host.querySelectorAll('button')).find(b => b.textContent === text)!.click(); await flush()
}
afterEach(() => { apps.splice(0).forEach(a => a.unmount()); document.body.innerHTML = ''; vi.resetAllMocks() })
describe('conversation goal history', () => {
it('loads only on demand and preserves paused/terminal goals and opaque cursors', async () => {
const page = Array.from({ length: 20 }, (_, i) => ({ id: String(9223372036854775800n - BigInt(i)), status: i ? 'paused' : 'completed' }))
vi.mocked(goalApi.history).mockResolvedValueOnce({ data: page } as any).mockResolvedValueOnce({ data: [] } as any)
const { host } = mount(); expect(goalApi.history).not.toHaveBeenCalled()
host.querySelector('button')!.click(); await flush()
expect(host.textContent).toContain('completed'); expect(host.textContent).toContain('paused')
await click(host, 'Older')
expect(goalApi.history).toHaveBeenLastCalledWith('owned-conversation', page.at(-1)!.id)
expect(host.textContent).not.toContain('Older')
})
it('discards delayed history when changing conversation or closing the panel', async () => {
let resolve!: (value: any) => void
vi.mocked(goalApi.history).mockImplementation(() => new Promise(r => { resolve = r }))
const { host, props } = mount(); host.querySelector('button')!.click(); await flush()
props.conversationId = 'new-conversation'; await flush()
resolve({ data: [{ id: 'old', status: 'paused' }] }); await flush()
expect(host.querySelector('[role=dialog]')).toBeNull(); expect(host.textContent).not.toContain('old')
host.querySelector('button')!.click(); await flush(); await click(host, 'Close')
resolve({ data: [{ id: 'late', status: 'completed' }] }); await flush()
expect(host.querySelector('[role=dialog]')).toBeNull()
})
it('discards an in-flight page when a cached route is deactivated', async () => {
let resolve!: (value: any) => void
vi.mocked(goalApi.history).mockImplementation(() => new Promise(r => { resolve = r }))
const { host, active } = mount(); host.querySelector('button')!.click(); await flush()
active.value = false; await flush()
resolve({ data: [{ id: 'hidden-route-goal', status: 'paused' }] }); await flush()
active.value = true; await flush()
expect(host.querySelector('[role=dialog]')).toBeNull()
expect(host.textContent).not.toContain('hidden-route-goal')
})
it('clears the visible page after a refresh loses access', async () => {
vi.mocked(goalApi.history).mockResolvedValueOnce({ data: [{ id: 'private-goal', status: 'paused' }] } as any)
.mockRejectedValueOnce({ code: 403 })
const { host } = mount(); host.querySelector('button')!.click(); await flush()
expect(host.textContent).toContain('private-goal'); await click(host, 'Refresh')
expect(host.textContent).not.toContain('private-goal'); expect(host.querySelector('[role=alert]')!.textContent).toContain('checking your access')
})
})

View File

@ -38,6 +38,9 @@ export default {
}
},
goalJsonAcceptance: {
historyLoadFailed: 'Could not load this conversations goals. Reload after checking your access.',
historyLoadMore: 'Load older goals',
historyStatus: { active: 'Active', paused: 'Paused', completed: 'Completed', exhausted: 'Budget exhausted', abandoned: 'Abandoned' },
"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",

View File

@ -38,6 +38,9 @@ export default {
}
},
goalJsonAcceptance: {
historyLoadFailed: '无法读取此会话的目标。请确认访问权限后重新读取。',
historyLoadMore: '加载更早的目标',
historyStatus: { active: '进行中', paused: '已暂停', completed: '已完成', exhausted: '预算耗尽', abandoned: '已放弃' },
"title": "受管 JSON 验收",
"scope": "要求平台受管 JSON 对象存在指定顶层字段且值不为 null允许 false、0 和空字符串)。文本声明和普通文件诊断不能满足这项要求。",
"refresh": "重新读取要求",

View File

@ -76,11 +76,8 @@
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
</div>
<div class="chat-header-right">
<button v-if="currentConversationGoal" class="header-btn" type="button"
:title="$t('plans.goals')" :aria-label="$t('plans.goals')"
:aria-expanded="conversationGoalOpen" @click="conversationGoalOpen = true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
</button>
<ConversationGoalsControl v-if="currentConversationId && !isEphemeralConversation(currentConversationId)"
:key="currentConversationId" :conversation-id="currentConversationId" />
<!-- Model selector Issue #81 v2 R3: always pass full providers + show-all-states
so unhealthy rows render as dimmed entries with status chips and a Fix
button instead of disappearing entirely. -->
@ -116,10 +113,6 @@
</div>
</div>
<GoalsPanel v-if="currentConversationGoal && conversationGoalOpen" :key="currentConversationGoal.id"
:open="conversationGoalOpen" :goals="[currentConversationGoal]" :loading="false"
@close="conversationGoalOpen = false" />
<TeamWorkerBanner
v-if="workerRunContext"
:run-id="workerRunContext.runId"
@ -360,7 +353,7 @@ import { buildViewerModelProviders } from '@/utils/viewerModelProviders'
import GoalSetInlinePrompt from '@/components/goal/GoalSetInlinePrompt.vue'
import GoalSystemLine from '@/components/goal/GoalSystemLine.vue'
const GoalsPanel = defineAsyncComponent(() => import('@/components/agents/GoalsPanel.vue'))
const ConversationGoalsControl = defineAsyncComponent(() => import('@/components/goal/ConversationGoalsControl.vue'))
// ============ Talk Mode ============
const showTalkMode = ref(false)
@ -1403,11 +1396,6 @@ watch([selectedAgentId, currentConversationId], () => {
// avatar ring listens on goalStore.activeGoalByConv[cid]; without this
// fetch the ring would only appear after an SSE event mutated the store.
const goalStore = useGoalStore()
const conversationGoalOpen = ref(false)
const currentConversationGoal = computed(() => currentConversationId.value
? goalStore.activeGoal(currentConversationId.value) : null)
watch(() => currentConversationGoal.value?.id, () => { conversationGoalOpen.value = false })
watch(currentConversationId, () => { conversationGoalOpen.value = false })
const workspaceStore = useWorkspaceStore()
const currentWorkspaceId = computed(() => workspaceStore.currentWorkspaceId ?? '1')
const canConfigureModels = computed(() => workspaceStore.isGlobalAdmin)