fix(team): stabilize runs and compact markdown delivery

This commit is contained in:
matevip 2026-08-14 03:25:47 -04:00
parent dc3506f748
commit d1592cc922
17 changed files with 417 additions and 35 deletions

View File

@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.team.model.TeamRunView;
@ -43,8 +44,9 @@ public class TeamRunController {
@RequireWorkspaceRole("viewer")
public R<List<TeamRunView>> listTeamRuns(
@PathVariable Long teamId,
@RequestParam(value = "activeOnly", defaultValue = "false") boolean activeOnly,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return guarded(() -> R.ok(runService.listTeamRuns(teamId, workspaceId(workspaceId))));
return guarded(() -> R.ok(runService.listTeamRuns(teamId, workspaceId(workspaceId), activeOnly)));
}
@Operation(summary = "List conversation team runs")

View File

@ -238,7 +238,34 @@ public class TeamPlanBridge {
if (!allTerminal) {
return new InFlight(buildProgressText(tasks));
}
return new Settled(plan.getId(), plan.getGoal(), steps, settle(plan.getId(), tasks));
List<String> results = settle(plan.getId(), tasks);
finalizeRunWithFallback(teamOpt.get().getWorkspaceId(), tasks, results);
return new Settled(plan.getId(), plan.getGoal(), steps, results);
}
/**
* The lead wake-up is the completion boundary for a delegated run. Do not
* leave the run in FINALIZING when the later LLM summary call fails.
*/
private void finalizeRunWithFallback(Long workspaceId, List<TeamTaskEntity> tasks,
List<String> results) {
Long runId = tasks.stream()
.map(TeamTaskEntity::getRunId)
.filter(id -> id != null)
.findFirst()
.orElse(null);
if (runId == null) {
return;
}
String fallback = "执行摘要(汇总模型不可用,以下为步骤原始结果):\n"
+ String.join("\n", results);
try {
runService.markFinalized(runId, workspaceId, fallback);
} catch (IllegalStateException error) {
// A concurrent projector may still be moving the run to FINALIZING.
// The next lead wake-up can retry; never wedge the conversation here.
log.warn("Unable to finalize settled team run {}: {}", runId, error.getMessage());
}
}
/** Sync the sub-plan mirror from terminal tasks and render step results. */

View File

@ -3,8 +3,10 @@ package vip.mate.team.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunCreateCommand;
@ -12,6 +14,7 @@ import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamRunView;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskMapper;
@ -23,6 +26,7 @@ import java.util.stream.Collectors;
/** Owns team run creation, lifecycle transitions, authorization, and reads. */
@Service
@Slf4j
public class TeamRunService {
public record SealResult(TeamRunEntity run, boolean transitioned) {
@ -100,11 +104,56 @@ public class TeamRunService {
return buildView(requireRun(runId, workspaceId));
}
/** Reconciles runs stranded after the optional LLM summary step failed. */
@Scheduled(fixedDelayString = "${mateclaw.team.finalizing-reconcile-ms:30000}", initialDelay = 30000)
@Transactional
public void reconcileFinalizingRuns() {
List<TeamRunEntity> runs = runMapper.selectList(Wrappers.<TeamRunEntity>lambdaQuery()
.eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING));
for (TeamRunEntity run : runs) {
List<TeamTaskEntity> tasks = tasksForRun(run.getId());
if (tasks.isEmpty() || tasks.stream().anyMatch(task -> !TeamTaskStatus.isTerminal(task.getStatus()))) {
continue;
}
String outcome = tasks.stream().allMatch(task -> TeamTaskStatus.COMPLETED.equals(task.getStatus()))
? TeamRunStatus.COMPLETED
: tasks.stream().anyMatch(task -> TeamTaskStatus.COMPLETED.equals(task.getStatus()))
? TeamRunStatus.PARTIAL : TeamRunStatus.FAILED;
String fallback = "执行摘要(汇总模型不可用,以下为步骤原始结果):\n"
+ tasks.stream().map(task -> {
String result = TeamTaskStatus.COMPLETED.equals(task.getStatus())
? task.getResult() : task.getReason();
return "- #" + task.getTaskNumber() + " " + (result == null ? task.getStatus() : result);
}).collect(Collectors.joining("\n"));
finalizeWithoutSummary(run, outcome, fallback);
}
}
private void finalizeWithoutSummary(TeamRunEntity run, String outcome, String summary) {
LocalDateTime completedAt = LocalDateTime.now();
runMapper.update(null, Wrappers.<TeamRunEntity>lambdaUpdate()
.eq(TeamRunEntity::getId, run.getId())
.eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING)
.set(TeamRunEntity::getStatus, outcome)
.set(TeamRunEntity::getFinalSummary, summary)
.set(TeamRunEntity::getCompletedAt, completedAt));
log.warn("Reconciled stranded team run {} from finalizing to {}", run.getId(), outcome);
}
public List<TeamRunView> listTeamRuns(Long teamId, Long workspaceId) {
return runMapper.selectList(Wrappers.<TeamRunEntity>lambdaQuery()
return listTeamRuns(teamId, workspaceId, false);
}
public List<TeamRunView> listTeamRuns(Long teamId, Long workspaceId, boolean activeOnly) {
var query = Wrappers.<TeamRunEntity>lambdaQuery()
.eq(TeamRunEntity::getTeamId, teamId)
.eq(TeamRunEntity::getWorkspaceId, workspaceId)
.orderByDesc(TeamRunEntity::getCreateTime))
.orderByDesc(TeamRunEntity::getCreateTime);
if (activeOnly) {
query.in(TeamRunEntity::getStatus, TeamRunStatus.PLANNING, TeamRunStatus.RUNNING,
TeamRunStatus.AWAITING_REVIEW, TeamRunStatus.FINALIZING);
}
return runMapper.selectList(query)
.stream().map(this::buildView).toList();
}

View File

@ -299,6 +299,7 @@ class TeamPlanBridgeTest {
assertTrue(settled.completedResults().get(1).contains("步骤2未完成"));
verify(planningService).updateSubPlanResult(PLAN_ID, 0, "卖点已产出");
verify(planningService).updateSubPlanFailure(eq(PLAN_ID), eq(1), anyString());
verify(runService).markFinalized(eq(RUN_ID), eq(WORKSPACE_ID), contains("执行摘要"));
}
@Test

View File

@ -1062,7 +1062,8 @@ export interface TeamRun {
export const teamRunApi = {
get: (runId: string) => http.get(`/team-runs/${runId}`),
listByTeam: (teamId: string) => http.get(`/teams/${teamId}/runs`),
listByTeam: (teamId: string, activeOnly = false) =>
http.get(`/teams/${teamId}/runs${activeOnly ? '?activeOnly=true' : ''}`),
listByConversation: (conversationId: string) =>
http.get(`/conversations/${encId(conversationId)}/team-runs`),
cancel: (runId: string, reason?: string) =>

View File

@ -1007,6 +1007,40 @@ html.dark .hljs-deletion { color: #e06c75; background: rgba(224, 108, 117, 0.1);
.markdown-body p { margin: 8px 0; }
.markdown-body ul, .markdown-body ol { padding-left: 1.5rem; margin: 8px 0; }
/* Compact layout for assistant delivery/results. These rules are global on
purpose: the rendered Markdown is injected with v-html, so component-scoped
selectors cannot reliably win against the shared document typography. */
.markdown-body.compact-markdown {
line-height: 1.5;
}
.markdown-body.compact-markdown p {
margin: 0 0 6px !important;
}
.markdown-body.compact-markdown p:empty,
.markdown-body.compact-markdown p:has(br:only-child) {
display: none;
}
.markdown-body.compact-markdown h1,
.markdown-body.compact-markdown h2,
.markdown-body.compact-markdown h3,
.markdown-body.compact-markdown h4,
.markdown-body.compact-markdown h5,
.markdown-body.compact-markdown h6 {
margin: 12px 0 6px !important;
line-height: 1.35;
}
.markdown-body.compact-markdown ul,
.markdown-body.compact-markdown ol {
margin: 6px 0 !important;
}
.markdown-body.compact-markdown li {
margin: 2px 0;
line-height: 1.5;
}
.markdown-body.compact-markdown li > p {
margin: 0 !important;
}
.markdown-body table {
border-collapse: collapse;
/* display:block + overflow-x:auto turns a wide table into its own horizontal

View File

@ -32,7 +32,7 @@ const { html: renderedContent } = useStreamingMarkdown(
<template>
<div class="seg-content">
<div class="markdown-body" v-html="renderedContent"></div>
<div class="markdown-body compact-markdown" v-html="renderedContent"></div>
<TypingCursor v-if="isRunning && showCursor" />
</div>
</template>

View File

@ -191,7 +191,7 @@
-->
<UserMessageContent v-if="role === 'user'" :content="displayContent" />
<template v-else>
<div class="markdown-body" v-html="renderedContent"></div>
<div class="markdown-body compact-markdown" v-html="renderedContent"></div>
<TypingCursor v-if="showCursor" :typing="isGenerating" />
</template>
</div>
@ -1766,6 +1766,58 @@ watch(isGenerating, (generating) => {
color: var(--mc-assistant-bubble-color, #1e293b);
}
/* Keep long delegated results readable without letting one response become a
full-width wall of text. The message column remains responsive on narrow
screens, while desktop reading stays close to a comfortable line length. */
.assistant-bubble .markdown-body {
max-width: 980px;
overflow-wrap: anywhere;
}
.assistant-bubble .markdown-body :deep(p) {
max-width: 920px;
margin: 0 0 6px !important;
line-height: 1.5;
}
.assistant-bubble .markdown-body :deep(p:empty) {
display: none;
}
.assistant-bubble .markdown-body :deep(p:has(br:only-child)) {
display: none;
}
.assistant-bubble .markdown-body :deep(> *) {
margin-block-start: 0;
margin-block-end: 6px;
}
.assistant-bubble .markdown-body :deep(> *:last-child) {
margin-block-end: 0;
}
.assistant-bubble .markdown-body :deep(h1),
.assistant-bubble .markdown-body :deep(h2),
.assistant-bubble .markdown-body :deep(h3) {
margin-top: 12px !important;
margin-bottom: 6px !important;
line-height: 1.35;
}
.assistant-bubble .markdown-body :deep(h2:first-child),
.assistant-bubble .markdown-body :deep(h3:first-child) {
margin-top: 4px;
}
.assistant-bubble .markdown-body :deep(ul),
.assistant-bubble .markdown-body :deep(ol) {
margin: 6px 0 !important;
}
.assistant-bubble .markdown-body :deep(li) {
margin: 2px 0;
line-height: 1.5;
}
.assistant-bubble .markdown-body :deep(li > p) {
margin: 0 !important;
}
.assistant-bubble .markdown-body :deep(hr) {
max-width: 920px;
margin: 12px 0;
}
.user-bubble {
background: var(--mc-user-bubble-bg, #D97757);
color: var(--mc-user-bubble-color, white);

View File

@ -3,8 +3,10 @@ import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown, UserFilled } from '@element-plus/icons-vue'
import type { Message } from '@/types'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
const { t } = useI18n()
const { renderMarkdown } = useMarkdownRenderer()
const props = defineProps<{
message: Message
@ -36,6 +38,7 @@ const displayText = computed(() => {
const cut = content.indexOf('\n\nReview these results')
return (cut > 0 ? content.slice(0, cut) : content).trim()
})
const displayHtml = computed(() => renderMarkdown(displayText.value))
</script>
<template>
@ -46,7 +49,7 @@ const displayText = computed(() => {
<el-icon class="team-announce__arrow" :class="{ 'is-open': expanded }" :size="12"><ArrowDown /></el-icon>
</button>
<Transition name="team-announce-slide">
<div v-if="expanded" class="team-announce__body">{{ displayText }}</div>
<div v-if="expanded" class="team-announce__body markdown-body" v-html="displayHtml" />
</Transition>
</div>
</template>
@ -95,15 +98,70 @@ const displayText = computed(() => {
margin: 0 12px 8px;
padding: 6px 0 6px 10px;
border-left: 2px solid var(--mc-border, rgba(0, 0, 0, 0.12));
font-size: 12px;
font-size: 13px;
line-height: 1.7;
color: var(--mc-text-secondary);
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
}
.team-announce__body :deep(p) {
margin: 0 0 8px;
}
.team-announce__body :deep(p:last-child) {
margin-bottom: 0;
}
.team-announce__body :deep(ul),
.team-announce__body :deep(ol) {
margin: 6px 0;
padding-left: 20px;
}
.team-announce__body :deep(li) {
margin: 3px 0;
}
.team-announce__body :deep(h1),
.team-announce__body :deep(h2),
.team-announce__body :deep(h3) {
margin: 10px 0 6px;
color: var(--mc-text-primary);
line-height: 1.35;
}
.team-announce__body :deep(table) {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 8px 0;
}
.team-announce__body :deep(th),
.team-announce__body :deep(td) {
border: 1px solid var(--mc-border);
padding: 5px 8px;
text-align: left;
white-space: nowrap;
}
.team-announce__body :deep(th) {
background: var(--mc-bg-subtle, rgba(0, 0, 0, 0.04));
color: var(--mc-text-primary);
}
.team-announce__body :deep(blockquote) {
margin: 8px 0;
padding-left: 10px;
border-left: 3px solid var(--mc-primary);
color: var(--mc-text-secondary);
}
.team-announce__body :deep(pre) {
max-width: 100%;
overflow-x: auto;
padding: 8px 10px;
border-radius: 7px;
background: var(--mc-bg-subtle, rgba(0, 0, 0, 0.05));
}
.team-announce__body :deep(code) {
overflow-wrap: anywhere;
}
.team-announce-slide-enter-active, .team-announce-slide-leave-active {
transition: all 0.2s ease;

View File

@ -53,12 +53,16 @@ const { t } = useI18n()
display: flex;
align-items: center;
gap: 10px;
margin: 8px 12px 0;
padding: 9px 12px;
flex: none;
position: relative;
z-index: 2;
margin: 10px 12px 0;
padding: 10px 14px;
border: 1px solid var(--mc-border, #d9e1e7);
border-left: 3px solid #b96c08;
border-radius: 6px;
border-radius: 10px;
background: var(--mc-panel, #fff);
box-shadow: 0 2px 8px rgba(41, 37, 36, 0.06);
color: var(--mc-text-primary, #1f2937);
letter-spacing: 0;
}
@ -66,8 +70,8 @@ const { t } = useI18n()
.worker-banner__copy { display: grid; min-width: 0; gap: 2px; font-size: 12px; }
.worker-banner__copy strong { font-size: 13px; }
.worker-banner__copy span { color: var(--mc-text-secondary, #64748b); }
.worker-banner__actions { display: flex; flex: none; gap: 6px; margin-left: auto; }
.worker-banner__actions button { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 4px 8px; border: 1px solid var(--mc-border, #d9e1e7); border-radius: 5px; background: transparent; color: inherit; cursor: pointer; letter-spacing: 0; }
.worker-banner__actions { display: flex; flex: none; gap: 5px; margin-left: auto; }
.worker-banner__actions button { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; padding: 3px 8px; border: 1px solid var(--mc-border, #d9e1e7); border-radius: 7px; background: var(--mc-panel, #fff); color: inherit; cursor: pointer; font-size: 13px; line-height: 1.2; letter-spacing: 0; white-space: nowrap; }
.worker-banner__actions button:hover { border-color: #1b8f68; color: #167454; }
.worker-banner__actions button:focus-visible { outline: 2px solid #1b8f68; outline-offset: 1px; }
@media (max-width: 720px) {

View File

@ -6,6 +6,7 @@ import type { TeamRun, TeamRunTask } from '@/api'
import TeamRunProgress from './TeamRunProgress.vue'
import TeamRunTaskList from './TeamRunTaskList.vue'
import { buildTeamRunRoute, extractRunDeliverables } from './teamRunPresentation'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
const props = withDefaults(defineProps<{
run: TeamRun
@ -23,11 +24,15 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { renderMarkdown } = useMarkdownRenderer()
const localTaskId = ref<string | null>(props.selectedTaskId)
watch(() => props.selectedTaskId, value => { localTaskId.value = value })
const selectedTask = computed(() => props.run.tasks.find(task => task.id === localTaskId.value) ?? null)
const deliverables = computed(() => extractRunDeliverables(props.run))
const terminal = computed(() => ['completed', 'partial', 'failed', 'cancelled'].includes(props.run.status))
const renderedSummary = computed(() => renderMarkdown(props.run.finalSummary || ''))
const renderedTaskDescription = computed(() => renderMarkdown(selectedTask.value?.description || ''))
const renderedTaskResult = computed(() => renderMarkdown(selectedTask.value?.result || ''))
function selectTask(task: TeamRunTask) {
localTaskId.value = task.id
@ -40,7 +45,8 @@ function selectTask(task: TeamRunTask) {
<section class="run-detail__summary">
<div class="run-detail__summary-copy">
<h4>{{ t('teamRuns.summary') }}</h4>
<p>{{ run.finalSummary || t('teamRuns.noSummary') }}</p>
<div v-if="run.finalSummary" class="run-detail__markdown markdown-body" v-html="renderedSummary" />
<p v-else>{{ t('teamRuns.noSummary') }}</p>
<dl>
<div>
<dt>{{ t('teamRuns.objective') }}</dt>
@ -92,7 +98,7 @@ function selectTask(task: TeamRunTask) {
@click="emit('navigate', buildTeamRunRoute(run.teamId, run.id, selectedTask.id))"
>{{ t('teamRuns.openTask') }}</button>
</div>
<p v-if="selectedTask.description">{{ selectedTask.description }}</p>
<div v-if="selectedTask.description" class="run-detail__markdown markdown-body" v-html="renderedTaskDescription" />
<dl>
<div>
<dt>{{ t('teamRuns.assignee') }}</dt>
@ -100,7 +106,8 @@ function selectTask(task: TeamRunTask) {
</div>
<div>
<dt>{{ t('teamRuns.result') }}</dt>
<dd class="run-detail__result">{{ selectedTask.result || t('teamRuns.noResult') }}</dd>
<dd v-if="selectedTask.result" class="run-detail__result run-detail__markdown markdown-body" v-html="renderedTaskResult" />
<dd v-else class="run-detail__result">{{ t('teamRuns.noResult') }}</dd>
</div>
</dl>
</section>
@ -118,6 +125,22 @@ function selectTask(task: TeamRunTask) {
.run-detail { border-top: 1px solid var(--mc-border-light, #e7ebef); letter-spacing: 0; }
.run-detail h4 { margin: 0; color: var(--mc-text-primary, #1f2937); font-size: 12px; font-weight: 700; }
.run-detail p { margin: 6px 0 0; color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
.run-detail__markdown { margin-top: 6px; color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.run-detail__markdown :deep(p) { margin: 0 0 8px; }
.run-detail__markdown :deep(p:last-child) { margin-bottom: 0; }
.run-detail__markdown :deep(h1), .run-detail__markdown :deep(h2), .run-detail__markdown :deep(h3), .run-detail__markdown :deep(h4) { margin: 12px 0 6px; color: var(--mc-text-primary, #1f2937); line-height: 1.3; }
.run-detail__markdown :deep(h1) { font-size: 18px; }
.run-detail__markdown :deep(h2) { font-size: 15px; }
.run-detail__markdown :deep(h3), .run-detail__markdown :deep(h4) { font-size: 13px; }
.run-detail__markdown :deep(ul), .run-detail__markdown :deep(ol) { margin: 6px 0 8px; padding-left: 20px; }
.run-detail__markdown :deep(li) { margin: 3px 0; }
.run-detail__markdown :deep(blockquote) { margin: 8px 0; padding: 6px 10px; border-left: 3px solid #9bcdbb; background: rgba(27, 143, 104, 0.05); }
.run-detail__markdown :deep(table) { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 8px 0; }
.run-detail__markdown :deep(th), .run-detail__markdown :deep(td) { padding: 5px 8px; border: 1px solid var(--mc-border-light, #e7ebef); text-align: left; white-space: nowrap; }
.run-detail__markdown :deep(th) { background: rgba(71, 85, 105, 0.06); color: var(--mc-text-primary, #1f2937); }
.run-detail__markdown :deep(code) { padding: 1px 4px; border-radius: 3px; background: rgba(71, 85, 105, 0.09); font-family: var(--mc-font-mono, ui-monospace, monospace); font-size: .92em; }
.run-detail__markdown :deep(pre) { max-width: 100%; overflow-x: auto; padding: 9px 10px; border-radius: 6px; background: var(--mc-code-bg, #faf6f1); }
.run-detail__markdown :deep(a) { color: #16795a; }
.run-detail__summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; background: rgba(71, 85, 105, 0.035); }
.run-detail__summary-copy { min-width: 0; flex: 1; }
.run-detail__section, .run-detail__task-detail { padding: 14px 16px; border-top: 1px solid var(--mc-border-light, #e7ebef); }

View File

@ -112,4 +112,18 @@ describe('TeamRunDetail', () => {
await nextTick()
expect(cancelled).toBe(1)
})
it('renders markdown in the run summary instead of one flattened paragraph', async () => {
const host = mount(TeamRunDetail, {
run: sampleRun({
status: 'completed',
finalSummary: '## 结论\n\n| 项目 | 状态 |\n| --- | --- |\n| 任务 | **完成** |',
}),
})
await nextTick()
expect(host.querySelector('.run-detail__markdown h2')?.textContent).toBe('结论')
expect(host.querySelector('.run-detail__markdown table')).not.toBeNull()
expect(host.querySelector('.run-detail__markdown strong')?.textContent).toBe('完成')
})
})

View File

@ -65,11 +65,11 @@ describe('projectAgentRunGroups', () => {
})
})
it.each([
['finalizing', 'finalizing'],
['cancelled', 'cancelled'],
] as const)('projects %s run state as %s', (status, expected) => {
expect(projectAgentRunGroups(snapshot([]), [run(status, [])]).groups[0].state).toBe(expected)
it('projects finalizing runs but excludes cancelled runs from the live view', () => {
expect(projectAgentRunGroups(snapshot([]), [run('finalizing', []), run('cancelled', [])]).groups)
.toHaveLength(1)
expect(projectAgentRunGroups(snapshot([]), [run('finalizing', []), run('cancelled', [])]).groups[0].state)
.toBe('finalizing')
})
})
@ -95,6 +95,17 @@ describe('useAgentRunGroups hydration priority', () => {
})
})
describe('useAgentRunGroups live scope', () => {
it('does not place terminal runs in the live team groups', () => {
const result = projectAgentRunGroups(snapshot([]), [
run('completed', []),
run('running', []),
])
expect(result.groups.map(group => group.run.status)).toEqual(['running'])
})
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => { resolve = done })

View File

@ -60,6 +60,21 @@ describe('useLiveSnapshot', () => {
expect(reconcile).toHaveBeenCalledOnce()
expect(live.snapshot.value).toBeNull()
})
it('stops blocking the initial view when the team run history is slow', async () => {
const history = deferred<void>()
const refreshRuns = vi.fn().mockReturnValue(history.promise)
const live = useLiveSnapshot({ load: vi.fn().mockResolvedValue({ data: snapshot('live') }), refreshRuns })
const request = live.refresh()
await Promise.resolve()
expect(live.snapshot.value?.runs[0].conversationId).toBe('live')
expect(live.loading.value).toBe(false)
expect(await request).toBe(true)
history.resolve()
})
})
function deferred<T>() {

View File

@ -60,7 +60,8 @@ export function projectAgentRunGroups(snapshot: LiveSnapshot | null, runs: reado
const liveRuns = snapshot?.runs ?? []
const liveByConversation = new Map(liveRuns.map(run => [run.conversationId, run]))
const claimed = new Set<string>()
const groups = runs.map((run) => {
const activeStatuses = new Set<TeamRun['status']>(['planning', 'running', 'awaiting_review', 'finalizing'])
const groups = runs.filter(run => activeStatuses.has(run.status)).map((run) => {
const leadRuntime = liveByConversation.get(run.leadConversationId) ?? null
if (leadRuntime) claimed.add(leadRuntime.conversationId)
const workers = run.tasks.map((task) => {
@ -76,10 +77,7 @@ export function projectAgentRunGroups(snapshot: LiveSnapshot | null, runs: reado
function relevantRuns(runs: TeamRun[], snapshot: LiveSnapshot | null): TeamRun[] {
const liveIds = new Set((snapshot?.runs ?? []).map(run => run.conversationId))
const activeStatuses = new Set(['planning', 'running', 'awaiting_review', 'finalizing'])
const terminalStatuses = new Set(['completed', 'partial', 'failed', 'cancelled'])
const recentCutoff = Date.now() - 86_400_000
return runs.filter(run => activeStatuses.has(run.status)
|| (terminalStatuses.has(run.status) && Date.parse(run.updateTime ?? run.createTime ?? '') >= recentCutoff)
|| liveIds.has(run.leadConversationId)
|| run.tasks.some(task => task.conversationId != null && liveIds.has(task.conversationId)))
}
@ -99,7 +97,9 @@ export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
try {
const teamsResponse: any = await teamApi.list()
const teams = teamsResponse?.data ?? []
const responses: any[] = await Promise.all(teams.map((entry: any) => teamRunApi.listByTeam(String(entry.team.id))))
const responses: any[] = await Promise.all(
teams.map((entry: any) => teamRunApi.listByTeam(String(entry.team.id), true)),
)
if (closed || request !== listSequence) return
const allRuns = responses.flatMap(response => response?.data ?? []) as TeamRun[]
listedRuns.value = relevantRuns(allRuns, snapshot.value)

View File

@ -19,7 +19,9 @@ export function useLiveSnapshot({ load, refreshRuns }: Dependencies) {
if (request !== sequence) return false
snapshot.value = response?.data ?? response as LiveSnapshot
error.value = null
await refreshRuns()
// Team history is supplementary. It must not hold the live snapshot
// behind a slow or oversized team-run response.
void refreshRuns().catch(() => undefined)
return request === sequence
} catch (cause) {
if (request === sequence) error.value = cause

View File

@ -426,11 +426,14 @@
</div>
<div v-if="currentTask.task.description" class="task-detail__block">
<div class="task-detail__label">{{ t('teams.taskDescription') }}</div>
<div class="task-detail__text">{{ currentTask.task.description }}</div>
<div class="task-detail__text task-detail__markdown markdown-body" v-html="renderedCurrentTaskDescription" />
</div>
<div v-if="currentTask.task.result" class="task-detail__block">
<div class="task-detail__label">{{ t('teams.result') }}</div>
<div class="task-detail__text task-detail__text--boxed">{{ currentTask.task.result }}</div>
<div
class="task-detail__text task-detail__text--boxed task-detail__markdown markdown-body"
v-html="renderedCurrentTaskResult"
/>
</div>
<div v-if="currentTask.task.reason" class="task-detail__reason">
{{ currentTask.task.reason }}
@ -462,7 +465,11 @@
{{ agentStore.agents.find(a => String(a.id) === String(ev.actorId))?.name || ev.actorId }}
</span>
<span v-else-if="ev.actorId" class="timeline-row__actor">{{ ev.actorId }}</span>
<span v-if="ev.detail" class="timeline-row__detail">{{ ev.detail }}</span>
<div
v-if="ev.detail"
class="timeline-row__detail timeline-row__markdown markdown-body"
v-html="renderTaskMarkdown(ev.detail)"
/>
</div>
</div>
</div>
@ -539,6 +546,7 @@ import {
type TeamsRouteState,
} from '@/composables/teamsRouteState'
import { useTeamRunHistory } from '@/composables/useTeamRunHistory'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { buildWorkerChatRoute } from '@/components/team-run/teamRunPresentation'
import TeamRunDrawer from '@/components/team-run/TeamRunDrawer.vue'
import TeamRunsPanel from '@/components/team-run/TeamRunsPanel.vue'
@ -553,6 +561,7 @@ const router = useRouter()
const store = useTeamStore()
const agentStore = useAgentStore()
const runHistory = useTeamRunHistory()
const { renderMarkdown } = useMarkdownRenderer()
const activeTab = ref<TeamsDetailView>('runs')
const taskDialogVisible = ref(false)
@ -560,6 +569,12 @@ const currentTask = ref<TeamTaskVO | null>(null)
const comments = ref<TeamTaskComment[]>([])
const newComment = ref('')
const taskEvents = ref<TeamTaskEvent[]>([])
const renderedCurrentTaskDescription = computed(() => renderMarkdown(currentTask.value?.task.description || ''))
const renderedCurrentTaskResult = computed(() => renderMarkdown(currentTask.value?.task.result || ''))
function renderTaskMarkdown(value: string | null | undefined): string {
return value ? renderMarkdown(value) : ''
}
let previousRouteState: TeamsRouteState | null = null
let routeReconciliationRevision = 0
@ -1672,6 +1687,76 @@ async function cancelTask() {
border: 1px solid var(--mc-border-light);
padding: 10px 12px;
}
.task-detail__markdown {
white-space: normal;
overflow-wrap: anywhere;
}
.task-detail__markdown :deep(p),
.timeline-row__markdown :deep(p) {
margin: 0 0 8px;
}
.task-detail__markdown :deep(p:last-child),
.timeline-row__markdown :deep(p:last-child) {
margin-bottom: 0;
}
.task-detail__markdown :deep(h1),
.task-detail__markdown :deep(h2),
.task-detail__markdown :deep(h3),
.timeline-row__markdown :deep(h1),
.timeline-row__markdown :deep(h2),
.timeline-row__markdown :deep(h3) {
margin: 10px 0 6px;
color: var(--mc-text-primary);
line-height: 1.35;
}
.task-detail__markdown :deep(ul),
.task-detail__markdown :deep(ol),
.timeline-row__markdown :deep(ul),
.timeline-row__markdown :deep(ol) {
margin: 6px 0;
padding-left: 20px;
}
.task-detail__markdown :deep(table),
.timeline-row__markdown :deep(table) {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 8px 0;
}
.task-detail__markdown :deep(th),
.task-detail__markdown :deep(td),
.timeline-row__markdown :deep(th),
.timeline-row__markdown :deep(td) {
border: 1px solid var(--mc-border);
padding: 5px 8px;
text-align: left;
white-space: nowrap;
}
.task-detail__markdown :deep(th),
.timeline-row__markdown :deep(th) {
background: var(--mc-bg-subtle, rgba(0, 0, 0, 0.04));
color: var(--mc-text-primary);
}
.task-detail__markdown :deep(blockquote),
.timeline-row__markdown :deep(blockquote) {
margin: 8px 0;
padding-left: 10px;
border-left: 3px solid var(--mc-primary);
color: var(--mc-text-secondary);
}
.task-detail__markdown :deep(pre),
.timeline-row__markdown :deep(pre) {
max-width: 100%;
overflow-x: auto;
padding: 8px 10px;
border-radius: 8px;
background: var(--mc-bg-subtle, rgba(0, 0, 0, 0.05));
}
.task-detail__markdown :deep(code),
.timeline-row__markdown :deep(code) {
overflow-wrap: anywhere;
}
.task-detail__reason {
border-radius: 12px;
border: 1px solid rgba(217, 119, 6, 0.3);
@ -1907,9 +1992,13 @@ async function cancelTask() {
flex-shrink: 0;
}
.timeline-row__detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
color: var(--mc-text-secondary);
overflow-wrap: anywhere;
}
.timeline-row__markdown {
white-space: normal;
}
.board-col__more {
width: 100%;