Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/chat-chain-changes/2026-07-05-group-chat-workspace-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
date: 2026-07-05
pr: pending
feature: Group Chat workspace diff audit messages
impact: Group Chat room-agent workspace runs persist bounded workspace_diff audit cards using Bridge-assigned run ids, exclude those audit cards from future model context, and fence stale or aborted run output before it can be saved.
---

Group Chat workspace runs now start a workspace diff checkpoint after Agent
Bridge returns its canonical `run_id`. WUI does not supply or override Bridge
run ids; the persisted audit row and tool-style room message follow the
Bridge-assigned run id.

When a room-agent run finishes, the server persists the bounded
`workspace_diff` message and matching `workspace_run_changes` row in one
database transaction. The persisted payload stores only the workspace basename,
keeps bounded file summaries and patches for the chat card, and avoids adding a
lazy group-chat file-detail endpoint.

Workspace diff audit messages are excluded from future Group Chat model context
and context token estimates, while still rendering as visible audit cards even
when generic tool traces are hidden. Client-supplied `workspace_diff` tool rows
are sanitized so only server-created audit cards keep that protected tool name.

Clear-context, delete-room, workspace-switch, and interrupt flows now fence the
current room-agent Bridge session before stale assistant/tool/workspace-diff
output can be persisted. Synchronized interrupts can finalize in-flight
diffs as aborted, unsynchronized interrupt failures leave the diff state
pending, and room interrupt pauses mention-queue draining so a queued mention
cannot start a new old-workspace run while the room is being reset.
122 changes: 120 additions & 2 deletions packages/client/src/components/hermes/group-chat/GroupMessageItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,23 @@ const copyableContent = computed(() => {

const toolExpanded = ref(false)
const isToolMessage = computed(() => props.message.role === 'tool')
const workspaceDiffPayload = computed(() => {
if ((props.message.toolName || props.message.tool_name) !== 'workspace_diff') return null
const raw = props.message.toolResult ?? props.message.content
if (!raw) return null
if (typeof raw === 'object' && (raw as any)?.kind === 'workspace_diff') return raw as any
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw)
return parsed?.kind === 'workspace_diff' ? parsed : null
} catch {
return null
}
}
return null
})
const workspaceDiffFiles = computed(() => Array.isArray(workspaceDiffPayload.value?.files) ? workspaceDiffPayload.value.files : [])
const workspaceDiffLabel = computed(() => workspaceDiffPayload.value?.workspace_basename || t('chat.workspace'))
const toolArgsPayload = computed(() => formatToolPayload(props.message.toolArgs))
const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult, true))
const hasToolDetails = computed(() => !!(toolArgsPayload.value.full || toolResultPayload.value.full))
Expand Down Expand Up @@ -490,7 +507,33 @@ onBeforeUnmount(() => {
<span class="sender-name">{{ message.senderName }}</span>
<span v-if="isAgent && agentInfo?.description" class="agent-desc">{{ agentInfo.description }}</span>
</div>
<div class="tool-line" :class="{ expandable: hasToolDetails }" @click="hasToolDetails && (toolExpanded = !toolExpanded)">
<div v-if="workspaceDiffPayload" class="workspace-diff-card">
<div class="workspace-diff-head">
<span class="workspace-diff-title">{{ t('chat.workspaceChanges') }}</span>
<span class="workspace-diff-status">{{ workspaceDiffPayload.status }}</span>
</div>
<div class="workspace-diff-meta" :title="workspaceDiffLabel">
<span>{{ workspaceDiffLabel }}</span>
<span>{{ t('chat.changedFiles', { files: workspaceDiffPayload.files_changed ?? workspaceDiffFiles.length }) }}</span>
<span class="diff-add">+{{ workspaceDiffPayload.additions || 0 }}</span>
<span class="diff-del">-{{ workspaceDiffPayload.deletions || 0 }}</span>
<span v-if="workspaceDiffPayload.truncated">{{ t('chat.truncated') }}</span>
</div>
<div class="workspace-diff-files" @click="handleToolDetailClick">
<div v-for="file in workspaceDiffFiles" :key="file.id || file.path" class="workspace-diff-file">
<div class="workspace-diff-file-head">
<span class="workspace-diff-path">{{ file.path }}</span>
<span>{{ file.change_type }}</span>
<span class="diff-add">+{{ file.additions || 0 }}</span>
<span class="diff-del">-{{ file.deletions || 0 }}</span>
<span v-if="file.binary">{{ t('chat.binaryFileDiffUnavailable') }}</span>
<span v-if="file.truncated">{{ t('chat.truncated') }}</span>
</div>
<div v-if="file.patch" class="tool-detail-code-block" v-html="renderToolPayload(file.patch, 'diff')"></div>
</div>
</div>
</div>
<div v-else class="tool-line" :class="{ expandable: hasToolDetails }" @click="hasToolDetails && (toolExpanded = !toolExpanded)">
<svg
v-if="hasToolDetails"
width="10"
Expand All @@ -512,7 +555,7 @@ onBeforeUnmount(() => {
<span v-if="message.toolStatus === 'running'" class="tool-spinner"></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{ t('chat.error') }}</span>
</div>
<div v-if="toolExpanded && hasToolDetails" class="tool-details" @click="handleToolDetailClick">
<div v-if="!workspaceDiffPayload && toolExpanded && hasToolDetails" class="tool-details" @click="handleToolDetailClick">
<div v-if="formattedToolArgs" class="tool-detail-section" data-copy-source="tool-args">
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
<div class="tool-detail-code-block" v-html="renderedToolArgs"></div>
Expand Down Expand Up @@ -756,6 +799,81 @@ onBeforeUnmount(() => {
padding-left: 10px;
}

.workspace-diff-card {
width: min(760px, 100%);
border: 1px solid $border-color;
border-radius: $radius-sm;
background: rgba(var(--accent-primary-rgb), 0.04);
overflow: hidden;
}

.workspace-diff-head,
.workspace-diff-meta,
.workspace-diff-file-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}

.workspace-diff-head {
justify-content: space-between;
padding: 8px 10px;
border-bottom: 1px solid $border-color;
}

.workspace-diff-title {
font-weight: 700;
color: $text-primary;
}

.workspace-diff-status {
font-size: 11px;
color: $text-secondary;
font-family: $font-code;
}

.workspace-diff-meta {
padding: 6px 10px;
font-size: 12px;
color: $text-secondary;
flex-wrap: wrap;
}

.workspace-diff-files {
display: grid;
gap: 8px;
padding: 8px 10px 10px;
}

.workspace-diff-file {
min-width: 0;
}

.workspace-diff-file-head {
padding: 4px 0;
font-size: 11px;
color: $text-muted;
font-family: $font-code;
}

.workspace-diff-path {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-primary;
}

.diff-add {
color: $success;
}

.diff-del {
color: $error;
}

.tool-detail-section {
margin-bottom: 6px;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ const { t } = useI18n()
const { toolTraceVisible } = useToolTraceVisibility()
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
const showScrollBottomButton = ref(false)
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
const displayMessages = computed(() => store.sortedMessages.filter(msg =>
msg.role !== 'tool' ||
toolTraceVisible.value ||
msg.toolStatus === 'running' ||
msg.toolName === 'workspace_diff' ||
msg.tool_name === 'workspace_diff',
))
const listPadding = computed(() => store.activePendingApproval ? '16px 20px 260px' : '16px 20px')
let pendingInitialBottomRoomId: string | null = store.currentRoomId

Expand Down
14 changes: 13 additions & 1 deletion packages/client/src/stores/hermes/group-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,16 @@ function runtimePayloadText(value: unknown): string {
return String(value)
}

function parseWorkspaceDiffPayload(value: unknown): unknown {
const text = runtimePayloadText(value)
if (!text) return undefined
try {
const parsed = JSON.parse(text)
return parsed?.kind === 'workspace_diff' ? parsed : value
} catch {
return value
}
}

function mapGroupMessages(msgs: ChatMessage[]): ChatMessage[] {
const toolNameMap = new Map<string, string>()
Expand Down Expand Up @@ -998,7 +1008,9 @@ function mapGroupMessages(msgs: ChatMessage[]): ChatMessage[] {
const toolName = msg.tool_name || toolNameMap.get(tcId) || undefined
const toolArgs = toolArgsMap.has(tcId) ? toolArgsMap.get(tcId) : undefined
let preview = ''
const toolResult = runtimeToolPayloadOrUndefined((msg as any).content)
const toolResult = toolName === 'workspace_diff'
? parseWorkspaceDiffPayload((msg as any).content)
: runtimeToolPayloadOrUndefined((msg as any).content)
const contentText = runtimePayloadText((msg as any).content)
if (contentText) {
try {
Expand Down
49 changes: 10 additions & 39 deletions packages/server/src/controllers/hermes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ import type { UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usa
import { deleteWorkspaceRunChangesForSession, getWorkspaceRunChangeFile as getWorkspaceRunChangeFileFromDb, listWorkspaceRunChangesForSession } from '../../db/hermes/workspace-run-changes-store'
import { getModelContextLength } from '../../services/hermes/model-context'
import { getActiveProfileName, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { isNearestExistingRealPathWithin, isPathWithin, isRealPathWithin } from '../../services/hermes/hermes-path'
import { isNearestExistingRealPathWithin, isPathWithin } from '../../services/hermes/hermes-path'
import {
isWorkspaceListPathAllowed,
normalizeWindowsWorkspacePath,
useWindowsDriveWorkspaceMode,
workspaceBaseOverride,
} from '../../services/hermes/workspace-path'
import { getGroupChatServer } from '../../routes/hermes/group-chat'
import { logger } from '../../services/logger'
import type { ConversationSummary } from '../../services/hermes/conversations'
Expand All @@ -30,7 +36,7 @@ import { AgentBridgeClient, getAgentBridgeManager } from '../../services/hermes/
import { ensureHermesRunWorkspace } from '../../services/hermes/run-chat/workspace'
import { isSensitivePath, MAX_EDIT_SIZE } from '../../services/hermes/file-provider'
import { readFile, stat as fsStat, writeFile } from 'fs/promises'
import { normalize as pathNormalize, resolve as pathResolve, win32 as pathWin32 } from 'path'
import { normalize as pathNormalize, resolve as pathResolve } from 'path'

function getPendingDeletedSessionIds(): Set<string> {
return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set<string>()
Expand Down Expand Up @@ -1103,30 +1109,6 @@ export async function usageStats(ctx: any) {
}
}

function workspaceBaseOverride(): string {
return process.env.WORKSPACE_BASE?.trim() || ''
}

function useWindowsDriveWorkspaceMode(): boolean {
return process.platform === 'win32' && !workspaceBaseOverride()
}

function windowsDriveRoot(pathValue: string): string | null {
const match = /^([a-zA-Z]:)[\\/]?$/.exec(pathValue.trim())
return match ? `${match[1].toUpperCase()}\\` : null
}

function normalizeWindowsWorkspacePath(inputPath: string): { base: string; fullPath: string } | null {
const raw = String(inputPath || '').trim()
if (!/^[a-zA-Z]:[\\/]/.test(raw)) return null
const fullPath = pathWin32.resolve(raw)
const root = windowsDriveRoot(pathWin32.parse(fullPath).root)
if (!root) return null
const rel = pathWin32.relative(root, fullPath)
if (rel.startsWith('..') || pathWin32.isAbsolute(rel)) return null
return { base: root, fullPath }
}

async function listWindowsWorkspaceDrives() {
const { existsSync } = await import('fs')
const drives = []
Expand All @@ -1143,23 +1125,12 @@ async function listWindowsWorkspaceDrives() {
return drives
}

async function isWorkspaceListPathAllowed(fullPath: string, basePath: string, statFn: any): Promise<boolean> {
try {
const info = await statFn(fullPath)
if (!info.isDirectory()) return false
if (process.platform === 'win32') return true
return await isRealPathWithin(fullPath, basePath)
} catch {
return false
}
}

async function isSafeWorkspaceFolderEntry(entry: any, fullPath: string, basePath: string, statFn: any): Promise<boolean> {
async function isSafeWorkspaceFolderEntry(entry: any, fullPath: string, basePath: string, statFn: any, options?: { trustWindowsJunctions?: boolean }): Promise<boolean> {
if (!entry.isDirectory() && !(typeof entry.isSymbolicLink === 'function' && entry.isSymbolicLink())) {
return false
}

return isWorkspaceListPathAllowed(fullPath, basePath, statFn)
return isWorkspaceListPathAllowed(fullPath, basePath, statFn, options)
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/db/hermes/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export const WORKSPACE_RUN_CHANGES_TABLE = 'workspace_run_changes'

export const WORKSPACE_RUN_CHANGES_SCHEMA: Record<string, string> = {
change_id: 'TEXT PRIMARY KEY',
room_id: "TEXT NOT NULL DEFAULT ''",
message_id: "TEXT NOT NULL DEFAULT ''",
session_id: 'TEXT NOT NULL',
run_id: 'TEXT NOT NULL DEFAULT \'\'',
source: 'TEXT NOT NULL DEFAULT \'run\'',
Expand Down Expand Up @@ -131,6 +133,7 @@ export const WORKSPACE_RUN_CHANGE_FILES_SCHEMA: Record<string, string> = {
export const WORKSPACE_RUN_CHANGES_INDEXES = {
idx_workspace_run_changes_session: 'CREATE INDEX IF NOT EXISTS idx_workspace_run_changes_session ON workspace_run_changes(session_id, created_at)',
idx_workspace_run_changes_run: 'CREATE INDEX IF NOT EXISTS idx_workspace_run_changes_run ON workspace_run_changes(run_id)',
idx_workspace_run_changes_room: 'CREATE INDEX IF NOT EXISTS idx_workspace_run_changes_room ON workspace_run_changes(room_id, created_at)',
}

export const WORKSPACE_RUN_CHANGE_FILES_INDEXES = {
Expand Down
Loading
Loading