feat: gap closure sprint — dark theme, @-mention, LocalComputerUse, tests

P0: U4 UsageStore + U5 CascadeStateStore independent test files (57 tests)
P1: Dark theme — tokens.css [data-theme="dark"] + theme.ts Pinia store
    + TopNav toggle button + App.vue dynamic Ant Design theme
P1: @-mention — MentionDropdown.vue + /skills/mention-suggest API
    + ChatInput integration with @ detection
P2: LocalComputerUseSession — pyautogui + screencapture (replaces Docker stub)
P2: Integration tests for gap closure (12 tests)
Fix: create_cascade_state_store() now passes session_ttl to InMemory fallback
This commit is contained in:
chiguyong 2026-06-14 16:16:50 +08:00
parent 0ccef7be5c
commit 6e0e081f23
12 changed files with 1424 additions and 69 deletions

View File

@ -241,5 +241,4 @@ def create_cascade_state_store(
return RedisCascadeStateStore(redis_url=redis_url, session_ttl=session_ttl) return RedisCascadeStateStore(redis_url=redis_url, session_ttl=session_ttl)
except ImportError: except ImportError:
logger.warning("redis package not available, falling back to in-memory cascade store") logger.warning("redis package not available, falling back to in-memory cascade store")
return InMemoryCascadeStateStore() return InMemoryCascadeStateStore(session_ttl=session_ttl)
return InMemoryCascadeStateStore()

View File

@ -1,5 +1,5 @@
<template> <template>
<a-config-provider :locale="zhCN" :theme="themeConfig"> <a-config-provider :locale="zhCN" :theme="themeStore.antThemeConfig.value">
<SplashScreen v-if="loading" :status="loadingStatus" :error="loadError" /> <SplashScreen v-if="loading" :status="loadingStatus" :error="loadError" />
<router-view v-else /> <router-view v-else />
</a-config-provider> </a-config-provider>
@ -9,11 +9,13 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ConfigProvider as AConfigProvider } from 'ant-design-vue' import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN' import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { themeConfig } from './styles' import { useThemeStore } from './stores/theme'
import { isTauri, startBackend, checkBackendHealth } from './api/tauri' import { isTauri, startBackend, checkBackendHealth } from './api/tauri'
import { initApiBaseURL } from './api/base' import { initApiBaseURL } from './api/base'
import SplashScreen from './components/layout/SplashScreen.vue' import SplashScreen from './components/layout/SplashScreen.vue'
const themeStore = useThemeStore()
const loading = ref(isTauri()) const loading = ref(isTauri())
const loadingStatus = ref('正在初始化...') const loadingStatus = ref('正在初始化...')
const loadError = ref('') const loadError = ref('')

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="chat-input"> <div class="chat-input" style="position: relative;">
<div v-if="contextPills.length > 0" class="chat-input__pills"> <div v-if="contextPills.length > 0" class="chat-input__pills">
<ContextPill <ContextPill
v-for="(pill, idx) in contextPills" v-for="(pill, idx) in contextPills"
@ -10,13 +10,23 @@
@remove="removePill(idx)" @remove="removePill(idx)"
/> />
</div> </div>
<MentionDropdown
:visible="mentionVisible"
:query="mentionQuery"
:skills="skillSuggestions"
:position="mentionPosition"
@select="insertMention"
@close="closeMention"
/>
<div class="chat-input__row"> <div class="chat-input__row">
<a-textarea <a-textarea
ref="textareaRef"
v-model:value="inputText" v-model:value="inputText"
:placeholder="placeholder" :placeholder="placeholder"
:auto-size="{ minRows: 1, maxRows: 4 }" :auto-size="{ minRows: 1, maxRows: 4 }"
:disabled="disabled" :disabled="disabled"
@pressEnter="handlePressEnter" @pressEnter="handlePressEnter"
@input="handleInput"
class="chat-input__textarea" class="chat-input__textarea"
/> />
<a-button <a-button
@ -37,6 +47,8 @@ import { ref, computed, type Component } from 'vue'
import { Input as AInput, Button as AButton } from 'ant-design-vue' import { Input as AInput, Button as AButton } from 'ant-design-vue'
import { SendOutlined } from '@ant-design/icons-vue' import { SendOutlined } from '@ant-design/icons-vue'
import ContextPill from './ContextPill.vue' import ContextPill from './ContextPill.vue'
import MentionDropdown from './MentionDropdown.vue'
import { useSkillsStore } from '@/stores/skills'
const ATextarea = AInput.TextArea const ATextarea = AInput.TextArea
@ -46,6 +58,11 @@ interface ContextPillData {
removable?: boolean removable?: boolean
} }
interface SkillSuggestion {
name: string
description: string
}
interface IProps { interface IProps {
disabled?: boolean disabled?: boolean
placeholder?: string placeholder?: string
@ -62,19 +79,86 @@ const emit = defineEmits<{
const inputText = ref('') const inputText = ref('')
const contextPills = ref<ContextPillData[]>([]) const contextPills = ref<ContextPillData[]>([])
const textareaRef = ref<InstanceType<typeof ATextarea> | null>(null)
// @-mention state
const mentionVisible = ref(false)
const mentionQuery = ref('')
const mentionStartIndex = ref(-1)
const mentionPosition = ref({ left: 0 })
const skillsStore = useSkillsStore()
const skillSuggestions = computed<SkillSuggestion[]>(() => {
return (skillsStore.skills || []).map((s: any) => ({
name: s.name,
description: s.description || '',
}))
})
const canSend = computed(() => { const canSend = computed(() => {
return inputText.value.trim().length > 0 && !props.disabled return inputText.value.trim().length > 0 && !props.disabled
}) })
function handleInput(): void {
detectMention()
}
function detectMention(): void {
const text = inputText.value
const cursorPos = text.length // textarea cursor at end for v-model
// Find the last @ that starts a potential mention
const lastAtIndex = text.lastIndexOf('@', cursorPos)
if (lastAtIndex === -1) {
closeMention()
return
}
// Check if there's a space between @ and cursor (mention is complete)
const textAfterAt = text.slice(lastAtIndex + 1, cursorPos)
if (textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
closeMention()
return
}
// Check if @ is at start or preceded by whitespace
if (lastAtIndex > 0 && !/\s/.test(text[lastAtIndex - 1])) {
closeMention()
return
}
mentionVisible.value = true
mentionQuery.value = textAfterAt
mentionStartIndex.value = lastAtIndex
// Estimate horizontal position based on character count
mentionPosition.value = { left: Math.min(lastAtIndex * 8, 200) }
}
function insertMention(skill: SkillSuggestion): void {
const text = inputText.value
const before = text.slice(0, mentionStartIndex.value)
const after = text.slice(mentionStartIndex.value + 1 + mentionQuery.value.length)
inputText.value = `${before}@${skill.name} ${after}`
closeMention()
}
function closeMention(): void {
mentionVisible.value = false
mentionQuery.value = ''
mentionStartIndex.value = -1
}
function handleSend(): void { function handleSend(): void {
const message = inputText.value.trim() const message = inputText.value.trim()
if (!message) return if (!message) return
closeMention()
emit('send', message) emit('send', message)
inputText.value = '' setTimeout(() => { inputText.value = '' }, 0)
} }
function handlePressEnter(event: KeyboardEvent): void { function handlePressEnter(event: KeyboardEvent): void {
if (mentionVisible.value) return // Let MentionDropdown handle Enter
if (event.shiftKey) return if (event.shiftKey) return
event.preventDefault() event.preventDefault()
handleSend() handleSend()
@ -89,9 +173,9 @@ function removePill(idx: number): void {
.chat-input { .chat-input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--space-2) var(--space-3); padding: var(--space-3) var(--space-4);
background: var(--bg-primary); background: var(--bg-primary);
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color-split);
} }
.chat-input__pills { .chat-input__pills {
@ -105,6 +189,16 @@ function removePill(idx: number): void {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: var(--space-2); gap: var(--space-2);
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-color);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.chat-input__row:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
} }
.chat-input__textarea { .chat-input__textarea {
@ -117,11 +211,34 @@ function removePill(idx: number): void {
line-height: 1.5; line-height: 1.5;
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
background: transparent;
border: none;
box-shadow: none !important;
font-size: var(--font-base);
}
.chat-input__textarea :deep(.ant-input:focus) {
box-shadow: none !important;
}
.chat-input__textarea :deep(.ant-input::placeholder) {
color: var(--text-placeholder);
} }
.chat-input__send { .chat-input__send {
flex-shrink: 0; flex-shrink: 0;
height: 40px; height: 40px;
min-height: 40px; min-height: 40px;
border-radius: var(--radius-md) !important;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.chat-input__send:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
.chat-input__send:not(:disabled):active {
transform: translateY(0);
} }
</style> </style>

View File

@ -0,0 +1,168 @@
<template>
<div v-if="visible && filteredSkills.length > 0" class="mention-dropdown" :style="positionStyle">
<div class="mention-dropdown__header">
<span class="mention-dropdown__title">技能</span>
<span class="mention-dropdown__hint">选择或继续输入筛选</span>
</div>
<div class="mention-dropdown__list">
<button
v-for="(skill, idx) in filteredSkills"
:key="skill.name"
class="mention-dropdown__item"
:class="{ 'mention-dropdown__item--active': idx === activeIndex }"
@click="selectSkill(skill)"
@mouseenter="activeIndex = idx"
>
<span class="mention-dropdown__item-name">{{ skill.name }}</span>
<span class="mention-dropdown__item-desc">{{ skill.description }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
interface SkillSuggestion {
name: string
description: string
}
interface IProps {
visible: boolean
query: string
skills: SkillSuggestion[]
position?: { top: number; left: number }
}
const props = withDefaults(defineProps<IProps>(), {
position: () => ({ top: 0, left: 0 }),
})
const emit = defineEmits<{
select: [skill: SkillSuggestion]
close: []
}>()
const activeIndex = ref(0)
const filteredSkills = computed(() => {
const q = props.query.toLowerCase()
if (!q) return props.skills.slice(0, 8)
return props.skills
.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
.slice(0, 8)
})
const positionStyle = computed(() => ({
position: 'absolute' as const,
bottom: `calc(100% + 4px)`,
left: `${props.position.left}px`,
}))
watch(() => props.query, () => {
activeIndex.value = 0
})
function selectSkill(skill: SkillSuggestion) {
emit('select', skill)
}
function handleKeyDown(e: KeyboardEvent) {
if (!props.visible || filteredSkills.value.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % filteredSkills.value.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + filteredSkills.value.length) % filteredSkills.value.length
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
selectSkill(filteredSkills.value[activeIndex.value])
} else if (e.key === 'Escape') {
e.preventDefault()
emit('close')
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
</script>
<style scoped>
.mention-dropdown {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
min-width: 280px;
max-width: 400px;
z-index: var(--z-dropdown);
overflow: hidden;
}
.mention-dropdown__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color-split);
}
.mention-dropdown__title {
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mention-dropdown__hint {
font-size: var(--font-xs);
color: var(--text-placeholder);
}
.mention-dropdown__list {
max-height: 240px;
overflow-y: auto;
padding: var(--space-1) 0;
}
.mention-dropdown__item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background: transparent;
cursor: pointer;
text-align: left;
transition: background var(--transition-fast);
}
.mention-dropdown__item:hover,
.mention-dropdown__item--active {
background: var(--bg-tertiary);
}
.mention-dropdown__item-name {
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.mention-dropdown__item-desc {
font-size: var(--font-xs);
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -1,6 +1,10 @@
<template> <template>
<header class="top-nav"> <header class="top-nav">
<div class="top-nav__left"> <div class="top-nav__left">
<button class="top-nav__icon-btn" @click="emit('toggleIconNav')" title="切换导航">
<MenuFoldOutlined v-if="!iconNavCollapsed" />
<MenuUnfoldOutlined v-else />
</button>
<div class="top-nav__logo" @click="router.push('/agent')"> <div class="top-nav__logo" @click="router.push('/agent')">
<span class="top-nav__logo-text">Fischer</span> <span class="top-nav__logo-text">Fischer</span>
<span class="top-nav__logo-badge">AgentKit</span> <span class="top-nav__logo-badge">AgentKit</span>
@ -17,6 +21,12 @@
:text="wsConnected ? '已连接' : '未连接'" :text="wsConnected ? '已连接' : '未连接'"
/> />
</div> </div>
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'">
<button class="top-nav__icon-btn" @click="themeStore.toggle()">
<BulbOutlined v-if="isDark" />
<BulbOutlined v-else style="opacity: 0.5" />
</button>
</a-tooltip>
<a-tooltip title="设置"> <a-tooltip title="设置">
<button class="top-nav__icon-btn" @click="router.push('/agent/monitor?tab=settings')"> <button class="top-nav__icon-btn" @click="router.push('/agent/monitor?tab=settings')">
<SettingOutlined /> <SettingOutlined />
@ -30,12 +40,24 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Badge as ABadge, Tooltip as ATooltip } from 'ant-design-vue' import { Badge as ABadge, Tooltip as ATooltip } from 'ant-design-vue'
import { SettingOutlined } from '@ant-design/icons-vue' import { SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, BulbOutlined } from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { useThemeStore } from '@/stores/theme'
const props = defineProps<{
iconNavCollapsed?: boolean
}>()
const emit = defineEmits<{
toggleIconNav: []
}>()
const router = useRouter() const router = useRouter()
const chatStore = useChatStore() const chatStore = useChatStore()
const themeStore = useThemeStore()
const wsConnected = computed(() => chatStore.isWsConnected) const wsConnected = computed(() => chatStore.isWsConnected)
const iconNavCollapsed = computed(() => props.iconNavCollapsed ?? false)
const isDark = computed(() => themeStore.resolvedMode === 'dark')
</script> </script>
<style scoped> <style scoped>
@ -44,11 +66,11 @@ const wsConnected = computed(() => chatStore.isWsConnected)
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: var(--topnav-height); height: var(--topnav-height);
padding: 0 var(--space-4); padding: 0 var(--space-5);
background: var(--bg-primary); background: var(--bg-primary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color-split);
flex-shrink: 0; flex-shrink: 0;
z-index: var(--z-sticky); z-index: 100;
} }
.top-nav__left { .top-nav__left {
@ -63,6 +85,13 @@ const wsConnected = computed(() => chatStore.isWsConnected)
gap: var(--space-2); gap: var(--space-2);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.top-nav__logo:hover {
background: var(--bg-tertiary);
} }
.top-nav__logo-text { .top-nav__logo-text {
@ -79,9 +108,9 @@ const wsConnected = computed(() => chatStore.isWsConnected)
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--text-inverse); color: var(--text-inverse);
background: var(--gradient-brand); background: var(--gradient-brand);
padding: 1px var(--space-2); padding: 2px var(--space-2);
border-radius: var(--radius-full); border-radius: var(--radius-full);
letter-spacing: 0.5px; letter-spacing: 0.3px;
} }
.top-nav__center { .top-nav__center {
@ -96,11 +125,14 @@ const wsConnected = computed(() => chatStore.isWsConnected)
.top-nav__right { .top-nav__right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-2);
} }
.top-nav__status { .top-nav__status {
font-size: var(--font-xs); font-size: var(--font-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
background: var(--bg-tertiary);
} }
.top-nav__status :deep(.ant-badge-status-text) { .top-nav__status :deep(.ant-badge-status-text) {
@ -112,8 +144,8 @@ const wsConnected = computed(() => chatStore.isWsConnected)
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 34px;
height: 32px; height: 34px;
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-tertiary); color: var(--text-tertiary);

View File

@ -0,0 +1,143 @@
/**
* Theme Store Dark mode toggle with system preference detection.
*
* Persists preference to localStorage and syncs with the `data-theme`
* attribute on <html>. Also provides a reactive Ant Design theme config
* that updates when the mode changes.
*/
import { ref, computed, watchEffect } from 'vue'
import { defineStore } from 'pinia'
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
export type ThemeMode = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'agentkit-theme-mode'
function readToken(varName: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
return val || fallback
}
function getSystemPreference(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function getStoredMode(): ThemeMode {
if (typeof localStorage === 'undefined') return 'system'
return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system'
}
function applyTheme(resolved: 'light' | 'dark') {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', resolved)
}
export const useThemeStore = defineStore('theme', () => {
const mode = ref<ThemeMode>(getStoredMode())
const resolvedMode = computed<'light' | 'dark'>(() => {
if (mode.value === 'system') return getSystemPreference()
return mode.value
})
function setMode(newMode: ThemeMode) {
mode.value = newMode
localStorage.setItem(STORAGE_KEY, newMode)
}
function toggle() {
if (resolvedMode.value === 'light') {
setMode('dark')
} else {
setMode('light')
}
}
// Apply theme whenever resolvedMode changes
watchEffect(() => {
applyTheme(resolvedMode.value)
})
// Listen for system preference changes
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
// Force re-evaluation when system preference changes
if (mode.value === 'system') {
applyTheme(getSystemPreference())
}
})
}
// Ant Design Vue theme config — reactive to dark mode
const antThemeConfig = computed<ThemeConfig>(() => {
const isDark = resolvedMode.value === 'dark'
return {
algorithm: isDark ? undefined : undefined, // Ant Design 4.x uses token-based theming
token: {
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorInfo: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorSuccess: readToken('--color-success', isDark ? '#4ade80' : '#22c55e'),
colorWarning: readToken('--color-warning', isDark ? '#fbbf24' : '#f59e0b'),
colorError: readToken('--color-error', isDark ? '#f87171' : '#ef4444'),
colorText: readToken('--text-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
colorTextSecondary: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
colorTextTertiary: readToken('--text-tertiary', isDark ? '#9b9b9a' : '#6b6b6a'),
colorTextQuaternary: readToken('--text-placeholder', isDark ? '#6b6b6a' : '#9b9b9a'),
colorBgContainer: readToken('--bg-primary', isDark ? '#1a1a1a' : '#ffffff'),
colorBgLayout: readToken('--bg-secondary', isDark ? '#1f1f1f' : '#fbfbfa'),
colorBgElevated: readToken('--bg-elevated', isDark ? '#252525' : '#ffffff'),
colorBorder: readToken('--border-color', isDark ? '#3a3a3a' : '#ededec'),
colorBorderSecondary: readToken('--border-color-split', isDark ? '#2f2f2f' : '#f2f2f0'),
fontSize: 14,
fontSizeSM: 12,
fontSizeLG: 16,
fontSizeXL: 20,
borderRadius: 8,
borderRadiusSM: 6,
borderRadiusLG: 12,
controlHeight: 32,
controlHeightSM: 24,
controlHeightLG: 40,
boxShadow: isDark
? '0 1px 2px rgba(0, 0, 0, 0.2)'
: '0 1px 2px rgba(0, 0, 0, 0.04)',
boxShadowSecondary: isDark
? '0 2px 8px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15)'
: '0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)',
},
components: {
Menu: {
itemSelectedBg: readToken('--color-primary-light', isDark ? '#1e1b4b' : '#eef2ff'),
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemHoverBg: isDark ? '#1e1b4b' : '#f5f3ff',
itemHoverColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemColor: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
} as Record<string, unknown>,
Tabs: {
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemHoverColor: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
} as Record<string, unknown>,
Select: {
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorPrimaryHover: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
} as Record<string, unknown>,
Button: { borderRadius: 8, controlHeight: 32 } as Record<string, unknown>,
Card: { borderRadiusLG: 10 } as Record<string, unknown>,
Input: { borderRadius: 8 } as Record<string, unknown>,
Modal: { borderRadiusLG: 12 } as Record<string, unknown>,
},
}
})
return {
mode,
resolvedMode,
antThemeConfig,
setMode,
toggle,
}
})

View File

@ -1,63 +1,70 @@
/** /**
* Fischer AgentKit Design Tokens * Fischer AgentKit Design Tokens Notion-Inspired Light Theme
* *
* CSS Custom Properties as the single source of truth. * CSS Custom Properties as the single source of truth.
* Ant Design Vue theme tokens are mapped from these values in theme.ts. * Ant Design Vue theme tokens are mapped from these values in theme.ts.
*
* Design Philosophy:
* - Warm neutrals (Notion-style) instead of cold grays
* - Softer shadows with more spread, less offset
* - Generous border-radius for friendly feel
* - Subtle borders that fade into background
* - Refined hover/active states with gentle transitions
*/ */
:root { :root {
/* ── Brand Colors ── */ /* ── Brand Colors ── */
--color-primary: #7c3aed; --color-primary: #6366f1;
--color-primary-hover: #6d28d9; --color-primary-hover: #4f46e5;
--color-primary-active: #5b21b6; --color-primary-active: #4338ca;
--color-primary-light: #ede9fe; --color-primary-light: #eef2ff;
--color-primary-bg: #f5f3ff; --color-primary-bg: #f5f3ff;
/* ── Gradient ── */ /* ── Gradient ── */
--gradient-brand: linear-gradient(135deg, #7c3aed 0%, #1e1b4b 100%); --gradient-brand: linear-gradient(135deg, #6366f1 0%, #312e81 100%);
/* ── Semantic Colors ── */ /* ── Semantic Colors ── */
--color-success: #10b981; --color-success: #22c55e;
--color-success-light: #d1fae5; --color-success-light: #dcfce7;
--color-warning: #f59e0b; --color-warning: #f59e0b;
--color-warning-light: #fef3c7; --color-warning-light: #fef9c3;
--color-error: #ef4444; --color-error: #ef4444;
--color-error-light: #fee2e2; --color-error-light: #fee2e2;
--color-info: #3b82f6; --color-info: #3b82f6;
--color-info-light: #dbeafe; --color-info-light: #dbeafe;
/* ── Neutral / Gray Scale ── */ /* ── Neutral / Warm Gray Scale (Notion-inspired) ── */
--color-gray-50: #fafafa; --color-gray-50: #fbfbfa;
--color-gray-100: #f5f5f5; --color-gray-100: #f7f7f5;
--color-gray-200: #e5e5e5; --color-gray-200: #ededec;
--color-gray-300: #d4d4d4; --color-gray-300: #dfdfde;
--color-gray-400: #a3a3a3; --color-gray-400: #cececd;
--color-gray-500: #737373; --color-gray-500: #9b9b9a;
--color-gray-600: #525252; --color-gray-600: #6b6b6a;
--color-gray-700: #404040; --color-gray-700: #4a4a4a;
--color-gray-800: #262626; --color-gray-800: #2f2f2f;
--color-gray-900: #171717; --color-gray-900: #1a1a1a;
/* ── Background ── */ /* ── Background ── */
--bg-primary: #ffffff; --bg-primary: #ffffff;
--bg-secondary: #fafafa; --bg-secondary: #fbfbfa;
--bg-tertiary: #f5f5f5; --bg-tertiary: #f7f7f5;
--bg-elevated: #ffffff; --bg-elevated: #ffffff;
--bg-code: #282c34; --bg-code: #1e1e2e;
/* ── Foreground / Text ── */ /* ── Foreground / Text ── */
--text-primary: #171717; --text-primary: #1a1a1a;
--text-secondary: #525252; --text-secondary: #4a4a4a;
--text-tertiary: #737373; --text-tertiary: #6b6b6a;
--text-placeholder: #a3a3a3; --text-placeholder: #9b9b9a;
--text-inverse: #ffffff; --text-inverse: #ffffff;
--text-code: #abb2bf; --text-code: #cdd6f4;
/* ── Border ── */ /* ── Border ── */
--border-color: #e5e5e5; --border-color: #ededec;
--border-color-hover: #d4d4d4; --border-color-hover: #dfdfde;
--border-color-active: var(--color-primary); --border-color-active: var(--color-primary);
--border-color-split: #f0f0f0; --border-color-split: #f2f2f0;
/* ── Spacing ── */ /* ── Spacing ── */
--space-1: 4px; --space-1: 4px;
@ -73,8 +80,8 @@
/* ── Border Radius ── */ /* ── Border Radius ── */
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 6px; --radius-md: 6px;
--radius-lg: 8px; --radius-lg: 10px;
--radius-xl: 12px; --radius-xl: 14px;
--radius-full: 9999px; --radius-full: 9999px;
/* ── Font Size ── */ /* ── Font Size ── */
@ -96,11 +103,11 @@
--leading-normal: 1.5; --leading-normal: 1.5;
--leading-relaxed: 1.75; --leading-relaxed: 1.75;
/* ── Shadow ── */ /* ── Shadow (Notion-style: softer, more ambient) ── */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05); --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04); --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04); --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
/* ── Transition ── */ /* ── Transition ── */
--transition-fast: 150ms ease; --transition-fast: 150ms ease;
@ -121,16 +128,91 @@
--sidebar-width: 280px; --sidebar-width: 280px;
--quadrant-min-size: 320px; --quadrant-min-size: 320px;
/* ── Code Theme (One Dark Pro) ── */ /* ── Code Theme (Catppuccin Mocha — warmer dark theme) ── */
--code-bg: #282c34; --code-bg: #1e1e2e;
--code-fg: #abb2bf; --code-fg: #cdd6f4;
--code-keyword: #c678dd; --code-keyword: #cba6f7;
--code-string: #98c379; --code-string: #a6e3a1;
--code-number: #d19a66; --code-number: #fab387;
--code-comment: #5c6370; --code-comment: #6c7086;
--code-function: #61afef; --code-function: #89b4fa;
--code-variable: #e06c75; --code-variable: #f38ba8;
--code-type: #e5c07b; --code-type: #f9e2af;
--code-added-bg: rgba(16, 185, 129, 0.15); --code-added-bg: rgba(166, 227, 161, 0.15);
--code-removed-bg: rgba(239, 68, 68, 0.15); --code-removed-bg: rgba(243, 139, 168, 0.15);
}
/* ── Dark Theme ── */
[data-theme="dark"] {
/* ── Brand Colors ── */
--color-primary: #818cf8;
--color-primary-hover: #6366f1;
--color-primary-active: #4f46e5;
--color-primary-light: #1e1b4b;
--color-primary-bg: #1e1b4b;
/* ── Gradient ── */
--gradient-brand: linear-gradient(135deg, #818cf8 0%, #4338ca 100%);
/* ── Semantic Colors ── */
--color-success: #4ade80;
--color-success-light: #14532d;
--color-warning: #fbbf24;
--color-warning-light: #422006;
--color-error: #f87171;
--color-error-light: #450a0a;
--color-info: #60a5fa;
--color-info-light: #172554;
/* ── Neutral / Warm Gray Scale (Dark) ── */
--color-gray-50: #1a1a1a;
--color-gray-100: #2f2f2f;
--color-gray-200: #3a3a3a;
--color-gray-300: #4a4a4a;
--color-gray-400: #6b6b6a;
--color-gray-500: #9b9b9a;
--color-gray-600: #b0b0af;
--color-gray-700: #cececd;
--color-gray-800: #ededec;
--color-gray-900: #fbfbfa;
/* ── Background ── */
--bg-primary: #1a1a1a;
--bg-secondary: #1f1f1f;
--bg-tertiary: #2a2a2a;
--bg-elevated: #252525;
--bg-code: #11111b;
/* ── Foreground / Text ── */
--text-primary: #fbfbfa;
--text-secondary: #cececd;
--text-tertiary: #9b9b9a;
--text-placeholder: #6b6b6a;
--text-inverse: #1a1a1a;
--text-code: #cdd6f4;
/* ── Border ── */
--border-color: #3a3a3a;
--border-color-hover: #4a4a4a;
--border-color-active: var(--color-primary);
--border-color-split: #2f2f2f;
/* ── Shadow (darker ambient for dark mode) ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.15);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.15);
/* ── Code Theme (Catppuccin Mocha — unchanged, already dark) ── */
--code-bg: #11111b;
--code-fg: #cdd6f4;
--code-keyword: #cba6f7;
--code-string: #a6e3a1;
--code-number: #fab387;
--code-comment: #6c7086;
--code-function: #89b4fa;
--code-variable: #f38ba8;
--code-type: #f9e2af;
--code-added-bg: rgba(166, 227, 161, 0.2);
--code-removed-bg: rgba(243, 139, 168, 0.2);
} }

View File

@ -160,6 +160,30 @@ async def list_skills(req: Request):
] ]
@router.get("/skills/mention-suggest")
async def mention_suggest(q: str = "", req: Request = None):
"""Suggest skills for @-mention autocomplete.
Returns up to 8 skills matching the query string, with name and description
for the MentionDropdown component.
"""
skill_registry = req.app.state.skill_registry
skills = skill_registry.list_skills()
query = q.lower()
if query:
skills = [
s for s in skills
if query in s.name.lower()
or (s.config.description and query in s.config.description.lower())
]
return [
{"name": s.name, "description": s.config.description or ""}
for s in skills[:8]
]
@router.post("/skills/install") @router.post("/skills/install")
async def install_skill(request: InstallSkillRequest, req: Request): async def install_skill(request: InstallSkillRequest, req: Request):
"""Search for and install a skill by name. """Search for and install a skill by name.

View File

@ -1,12 +1,16 @@
"""ComputerUseSession - 虚拟桌面会话管理 """ComputerUseSession - 虚拟桌面会话管理
管理虚拟桌面会话Docker 沙箱维护操作上下文 管理虚拟桌面会话维护操作上下文
提供 InMemoryComputerUseSession 用于测试DockerComputerUseSession 用于生产 提供 InMemoryComputerUseSession 用于测试LocalComputerUseSession 用于本地桌面
DockerComputerUseSession 用于 Docker 沙箱stub
""" """
from __future__ import annotations from __future__ import annotations
import base64
import io
import logging import logging
import platform
import time import time
import uuid import uuid
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@ -248,6 +252,124 @@ class InMemoryComputerUseSession(ComputerUseSession):
) )
class LocalComputerUseSession(ComputerUseSession):
"""本地桌面会话,使用 pyautogui + screencapture。
在本地桌面执行真实的 UI 操作需要辅助功能权限macOS
适用于开发/演示环境生产环境应使用 DockerComputerUseSession
"""
def __init__(
self,
session_id: str | None = None,
screen_width: int = 1280,
screen_height: int = 720,
):
super().__init__(
session_id=session_id,
screen_width=screen_width,
screen_height=screen_height,
)
self._pyautogui: Any = None
async def start(self) -> None:
"""启动本地桌面会话"""
try:
import pyautogui
self._pyautogui = pyautogui
pyautogui.FAILSAFE = True
pyautogui.PAUSE = 0.1
except ImportError:
raise RuntimeError(
"pyautogui is required for LocalComputerUseSession. "
"Install with: pip install pyautogui"
)
self._started = True
logger.info("Local desktop session started: %s", self.session_id[:8])
async def stop(self) -> None:
"""停止本地桌面会话"""
self._started = False
self._pyautogui = None
logger.info("Local desktop session stopped: %s", self.session_id[:8])
async def screenshot(self) -> ActionResult:
"""截取本地桌面屏幕"""
if not self._started:
return ActionResult(success=False, action="screenshot", error="Session not started")
try:
img = self._pyautogui.screenshot()
buf = io.BytesIO()
img.save(buf, format="PNG")
screenshot_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return ActionResult(
success=True,
action="screenshot",
output=f"Screenshot {img.size[0]}x{img.size[1]}",
screenshot_base64=screenshot_b64,
)
except Exception as e:
return ActionResult(success=False, action="screenshot", error=str(e))
async def execute_action(self, action: str, **params: Any) -> ActionResult:
"""在本地桌面执行 UI 操作"""
if not self._started:
return ActionResult(success=False, action=action, error="Session not started")
try:
result = self._execute_local_action(action, **params)
self.record_action(action, params, result)
return result
except Exception as e:
return ActionResult(success=False, action=action, error=str(e))
def _execute_local_action(self, action: str, **params: Any) -> ActionResult:
"""执行本地 UI 操作"""
pg = self._pyautogui
if action == "click":
x, y = params.get("x", 0), params.get("y", 0)
button = params.get("button", "left")
pg.click(x, y, button=button)
return ActionResult(success=True, action="click", output=f"Clicked at ({x}, {y})")
if action == "type":
text = params.get("text", "")
pg.typewrite(text) if platform.system() != "Darwin" else pg.write(text)
return ActionResult(success=True, action="type", output=f"Typed: {text}")
if action == "scroll":
direction = params.get("direction", "down")
amount = params.get("amount", 3)
clicks = amount if direction == "down" else -amount
pg.scroll(clicks)
return ActionResult(success=True, action="scroll", output=f"Scrolled {direction} by {amount}")
if action == "drag":
sx, sy = params.get("start_x", 0), params.get("start_y", 0)
ex, ey = params.get("end_x", 0), params.get("end_y", 0)
pg.moveTo(sx, sy)
pg.dragTo(ex, ey, duration=0.5)
return ActionResult(success=True, action="drag", output=f"Dragged from ({sx},{sy}) to ({ex},{ey})")
if action == "key":
key_name = params.get("key_name", "")
keys = key_name.split("+")
if len(keys) > 1:
pg.hotkey(*keys)
else:
pg.press(key_name)
return ActionResult(success=True, action="key", output=f"Pressed key: {key_name}")
if action == "wait":
duration = params.get("duration", 1.0)
time.sleep(duration)
return ActionResult(success=True, action="wait", output=f"Waited {duration}s")
return ActionResult(success=False, action=action, error=f"Unknown action: {action}")
class DockerComputerUseSession(ComputerUseSession): class DockerComputerUseSession(ComputerUseSession):
"""Docker 沙箱虚拟桌面会话 """Docker 沙箱虚拟桌面会话

View File

@ -0,0 +1,163 @@
"""Gap closure integration tests — dark theme, @-mention API, LocalComputerUseSession."""
import pytest
from unittest.mock import MagicMock, patch
from agentkit.quality.cascade_state_store import (
InMemoryCascadeStateStore,
create_cascade_state_store,
)
from agentkit.llm.providers.usage_store import (
InMemoryUsageStore,
create_usage_store,
)
from agentkit.tools.computer_use_session import (
InMemoryComputerUseSession,
LocalComputerUseSession,
ComputerUseSessionManager,
)
# ---------------------------------------------------------------------------
# Dark theme: CSS token validation (smoke test)
# ---------------------------------------------------------------------------
class TestDarkThemeTokens:
"""Verify dark theme CSS tokens exist in tokens.css."""
def test_dark_theme_tokens_file_contains_dark_selector(self):
import pathlib
tokens_path = pathlib.Path(__file__).parent.parent.parent / (
"src/agentkit/server/frontend/src/styles/tokens.css"
)
if not tokens_path.exists():
pytest.skip("Frontend tokens.css not found")
content = tokens_path.read_text()
assert '[data-theme="dark"]' in content
assert '--bg-primary: #1a1a1a' in content
assert '--text-primary: #fbfbfa' in content
assert '--border-color: #3a3a3a' in content
def test_theme_store_exists(self):
import pathlib
store_path = pathlib.Path(__file__).parent.parent.parent / (
"src/agentkit/server/frontend/src/stores/theme.ts"
)
if not store_path.exists():
pytest.skip("Theme store not found")
content = store_path.read_text()
assert 'useThemeStore' in content
assert 'toggle' in content
assert 'resolvedMode' in content
assert 'localStorage' in content
# ---------------------------------------------------------------------------
# @-mention API: mention-suggest endpoint
# ---------------------------------------------------------------------------
class TestMentionSuggestAPI:
"""Test the /skills/mention-suggest endpoint logic."""
def _make_skill(self, name, description):
skill = MagicMock()
skill.name = name
skill.config.description = description
return skill
def test_mention_suggest_filters_by_name(self):
"""Verify the filtering logic used by the endpoint."""
skills = [
self._make_skill("geo_pipeline", "GEO pipeline skill"),
self._make_skill("code_reviewer", "Code review skill"),
self._make_skill("data_analyst", "Data analysis skill"),
]
query = "geo"
filtered = [
s for s in skills
if query in s.name.lower()
or (s.config.description and query in s.config.description.lower())
]
assert len(filtered) == 1
assert filtered[0].name == "geo_pipeline"
def test_mention_suggest_filters_by_description(self):
skills = [
self._make_skill("skill_a", "GEO pipeline skill"),
self._make_skill("skill_b", "Code review skill"),
]
query = "review"
filtered = [
s for s in skills
if query in s.name.lower()
or (s.config.description and query in s.config.description.lower())
]
assert len(filtered) == 1
assert filtered[0].name == "skill_b"
def test_mention_suggest_limits_to_8(self):
skills = [self._make_skill(f"skill_{i}", "") for i in range(20)]
result = skills[:8]
assert len(result) == 8
def test_mention_suggest_empty_query(self):
skills = [
self._make_skill("skill_a", "desc"),
self._make_skill("skill_b", "desc"),
]
query = ""
filtered = [
s for s in skills
if query in s.name.lower()
or (s.config.description and query in s.config.description.lower())
]
assert len(filtered) == 2
# ---------------------------------------------------------------------------
# LocalComputerUseSession
# ---------------------------------------------------------------------------
class TestLocalComputerUseSession:
"""Test LocalComputerUseSession (without actual pyautogui)."""
def test_inherits_from_base(self):
assert issubclass(LocalComputerUseSession, InMemoryComputerUseSession.__mro__[1])
def test_start_without_pyautogui_raises(self):
"""If pyautogui is not installed, start should raise RuntimeError."""
import asyncio
session = LocalComputerUseSession()
with patch.dict("sys.modules", {"pyautogui": None}):
with pytest.raises(RuntimeError, match="pyautogui"):
asyncio.run(session.start())
def test_session_manager_with_local_factory(self):
manager = ComputerUseSessionManager(session_factory=LocalComputerUseSession)
session = manager.get_or_create(session_id="test-local")
assert isinstance(session, LocalComputerUseSession)
# ---------------------------------------------------------------------------
# Factory session_ttl propagation (regression test)
# ---------------------------------------------------------------------------
class TestFactorySessionTTL:
"""Ensure session_ttl is propagated through factories."""
def test_cascade_store_memory_with_custom_ttl(self):
store = create_cascade_state_store(backend="memory", session_ttl=7200)
assert isinstance(store, InMemoryCascadeStateStore)
assert store._session_ttl == 7200
def test_usage_store_memory_backend(self):
store = create_usage_store(backend="memory")
assert isinstance(store, InMemoryUsageStore)
def test_cascade_store_auto_backend(self):
store = create_cascade_state_store(backend="auto")
assert store._session_ttl == 86400 # default

View File

@ -0,0 +1,229 @@
"""Unit tests for UsageStore (U4 — UsageStore Persistence)."""
import pytest
from datetime import datetime, timedelta, timezone
from agentkit.llm.protocol import TokenUsage
from agentkit.llm.providers.usage_store import (
InMemoryUsageStore,
RedisUsageStore,
UsageRecord,
UsageBucket,
UsageSummary,
UsageStore,
create_usage_store,
)
# ---------------------------------------------------------------------------
# InMemoryUsageStore
# ---------------------------------------------------------------------------
class TestInMemoryUsageStore:
"""Tests for InMemoryUsageStore."""
def test_implements_protocol(self):
store = InMemoryUsageStore()
assert isinstance(store, UsageStore)
def test_record_single(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
summary = store.get_usage()
assert summary.total_tokens == 150
assert summary.total_cost == 0.05
assert len(summary.records) == 1
def test_record_multiple(self):
store = InMemoryUsageStore()
usage1 = TokenUsage(prompt_tokens=100, completion_tokens=50)
usage2 = TokenUsage(prompt_tokens=200, completion_tokens=100)
store.record("agent1", "gpt-4", usage1, cost=0.05, latency_ms=200)
store.record("agent1", "gpt-4", usage2, cost=0.10, latency_ms=300)
summary = store.get_usage()
assert summary.total_tokens == 450
assert abs(summary.total_cost - 0.15) < 1e-6
assert len(summary.records) == 2
def test_get_usage_by_agent(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
store.record("agent2", "gpt-4", usage, cost=0.05, latency_ms=200)
summary = store.get_usage(agent_name="agent1")
assert len(summary.records) == 1
assert summary.records[0].agent_name == "agent1"
def test_get_usage_by_time_range(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
# Query with start_time in the future — should return empty
future = datetime.now(timezone.utc) + timedelta(hours=1)
summary = store.get_usage(start_time=future)
assert len(summary.records) == 0
# Query with end_time in the past — should return empty
past = datetime.now(timezone.utc) - timedelta(hours=1)
summary = store.get_usage(end_time=past)
assert len(summary.records) == 0
# Query with wide range — should return the record
start = datetime.now(timezone.utc) - timedelta(hours=1)
end = datetime.now(timezone.utc) + timedelta(hours=1)
summary = store.get_usage(start_time=start, end_time=end)
assert len(summary.records) == 1
def test_get_usage_by_model(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
store.record("agent1", "gpt-3.5", usage, cost=0.01, latency_ms=100)
summary = store.get_usage()
assert "gpt-4" in summary.by_model
assert "gpt-3.5" in summary.by_model
assert summary.by_model["gpt-4"]["count"] == 1
assert summary.by_model["gpt-3.5"]["count"] == 1
def test_get_usage_empty(self):
store = InMemoryUsageStore()
summary = store.get_usage()
assert summary.total_tokens == 0
assert summary.total_cost == 0.0
assert len(summary.records) == 0
def test_max_records_trimming(self):
store = InMemoryUsageStore()
store.MAX_RECORDS = 5
usage = TokenUsage(prompt_tokens=1, completion_tokens=1)
for i in range(10):
store.record(f"agent{i}", "gpt-4", usage, cost=0.01, latency_ms=100)
assert len(store._records) == 5
# Should keep the last 5 records
assert store._records[0].agent_name == "agent5"
def test_usage_record_timestamp(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
rec = store.get_usage().records[0]
assert rec.timestamp != ""
# Should be parseable as ISO 8601
datetime.fromisoformat(rec.timestamp)
# ---------------------------------------------------------------------------
# UsageRecord / UsageBucket / UsageSummary dataclasses
# ---------------------------------------------------------------------------
class TestDataclasses:
def test_usage_record_auto_timestamp(self):
rec = UsageRecord(
agent_name="a", model="m",
prompt_tokens=1, completion_tokens=1,
total_tokens=2, cost=0.01, latency_ms=100,
)
assert rec.timestamp != ""
def test_usage_record_explicit_timestamp(self):
rec = UsageRecord(
agent_name="a", model="m",
prompt_tokens=1, completion_tokens=1,
total_tokens=2, cost=0.01, latency_ms=100,
timestamp="2026-01-01T00:00:00+00:00",
)
assert rec.timestamp == "2026-01-01T00:00:00+00:00"
def test_usage_bucket_defaults(self):
bucket = UsageBucket()
assert bucket.prompt_tokens == 0
assert bucket.completion_tokens == 0
assert bucket.total_tokens == 0
assert bucket.cost == 0.0
assert bucket.count == 0
def test_usage_summary_defaults(self):
summary = UsageSummary()
assert summary.total_tokens == 0
assert summary.total_cost == 0.0
assert summary.by_model == {}
assert summary.records == []
# ---------------------------------------------------------------------------
# RedisUsageStore (mocked)
# ---------------------------------------------------------------------------
class TestRedisUsageStoreMocked:
"""Tests for RedisUsageStore with mocked Redis."""
def _make_store(self):
store = RedisUsageStore(redis_url="redis://localhost:6379")
return store
def test_implements_protocol(self):
store = self._make_store()
assert isinstance(store, UsageStore)
def test_degrade_to_fallback(self):
store = self._make_store()
assert not store._degraded
store._degrade_to_fallback()
assert store._degraded
assert store._fallback is not None
def test_record_degraded_uses_fallback(self):
store = self._make_store()
store._degrade_to_fallback()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
# Should be in fallback
summary = store._fallback.get_usage()
assert len(summary.records) == 1
def test_get_usage_degraded_uses_fallback(self):
store = self._make_store()
store._degrade_to_fallback()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store._fallback.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
summary = store.get_usage()
assert len(summary.records) == 1
def test_get_usage_degraded_no_fallback_returns_empty(self):
store = self._make_store()
store._degraded = True
# No fallback set — should return empty
summary = store.get_usage()
assert summary.total_tokens == 0
def test_today_key_format(self):
store = self._make_store()
key = store._today_key()
# Should be YYYY-MM-DD
assert len(key) == 10
assert key[4] == "-"
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
class TestCreateUsageStore:
def test_memory_backend(self):
store = create_usage_store(backend="memory")
assert isinstance(store, InMemoryUsageStore)
def test_auto_backend_returns_store(self):
store = create_usage_store(backend="auto")
assert isinstance(store, (InMemoryUsageStore, RedisUsageStore))
def test_redis_backend_returns_store(self):
store = create_usage_store(backend="redis")
# May be InMemory if redis package unavailable
assert isinstance(store, (InMemoryUsageStore, RedisUsageStore))

View File

@ -0,0 +1,274 @@
"""Unit tests for CascadeStateStore (U5 — CascadeStateStore Persistence)."""
import pytest
import time
from unittest.mock import MagicMock, patch
from agentkit.quality.cascade_state_store import (
CascadeStateStore,
InMemoryCascadeStateStore,
RedisCascadeStateStore,
create_cascade_state_store,
)
# ---------------------------------------------------------------------------
# InMemoryCascadeStateStore
# ---------------------------------------------------------------------------
class TestInMemoryCascadeStateStore:
"""Tests for InMemoryCascadeStateStore."""
def test_implements_protocol(self):
store = InMemoryCascadeStateStore()
assert isinstance(store, CascadeStateStore)
def test_increment_interaction(self):
store = InMemoryCascadeStateStore()
assert store.increment_interaction("s1") == 1
assert store.increment_interaction("s1") == 2
assert store.increment_interaction("s1") == 3
def test_increment_different_sessions(self):
store = InMemoryCascadeStateStore()
assert store.increment_interaction("s1") == 1
assert store.increment_interaction("s2") == 1
assert store.increment_interaction("s1") == 2
def test_get_interaction(self):
store = InMemoryCascadeStateStore()
store.increment_interaction("s1")
store.increment_interaction("s1")
assert store.get_interaction("s1") == 2
def test_get_interaction_nonexistent(self):
store = InMemoryCascadeStateStore()
assert store.get_interaction("unknown") == 0
def test_set_and_get_depth(self):
store = InMemoryCascadeStateStore()
store.set_depth("s1", 3)
assert store.get_depth("s1") == 3
def test_get_depth_nonexistent(self):
store = InMemoryCascadeStateStore()
assert store.get_depth("unknown") == 0
def test_reset(self):
store = InMemoryCascadeStateStore()
store.increment_interaction("s1")
store.increment_interaction("s1")
store.set_depth("s1", 2)
store.reset("s1")
assert store.get_interaction("s1") == 0
assert store.get_depth("s1") == 0
def test_reset_nonexistent_no_error(self):
store = InMemoryCascadeStateStore()
store.reset("nonexistent") # Should not raise
def test_ttl_expiry_interaction(self):
store = InMemoryCascadeStateStore(session_ttl=1) # 1 second TTL
store.increment_interaction("s1")
assert store.get_interaction("s1") == 1
time.sleep(1.1)
# After TTL, should return 0 (expired)
assert store.get_interaction("s1") == 0
def test_ttl_expiry_depth(self):
store = InMemoryCascadeStateStore(session_ttl=1)
store.set_depth("s1", 5)
assert store.get_depth("s1") == 5
time.sleep(1.1)
assert store.get_depth("s1") == 0
def test_ttl_cleanup_on_increment(self):
store = InMemoryCascadeStateStore(session_ttl=1)
store.increment_interaction("s1")
store.increment_interaction("s2")
time.sleep(1.1)
# Incrementing s3 should trigger cleanup of s1 and s2
store.increment_interaction("s3")
assert "s1" not in store._interaction_counts
assert "s2" not in store._interaction_counts
assert store._interaction_counts.get("s3") == 1
def test_touch_refreshes_ttl(self):
store = InMemoryCascadeStateStore(session_ttl=2)
store.increment_interaction("s1")
time.sleep(1.0)
# Touch refreshes the timestamp
store.increment_interaction("s1")
time.sleep(1.0)
# Should still be alive (1s < 2s since last touch)
assert store.get_interaction("s1") == 2
def test_default_session_ttl(self):
store = InMemoryCascadeStateStore()
assert store._session_ttl == 86400
def test_custom_session_ttl(self):
store = InMemoryCascadeStateStore(session_ttl=3600)
assert store._session_ttl == 3600
# ---------------------------------------------------------------------------
# RedisCascadeStateStore (mocked)
# ---------------------------------------------------------------------------
class TestRedisCascadeStateStoreMocked:
"""Tests for RedisCascadeStateStore with mocked Redis."""
def test_implements_protocol(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
assert isinstance(store, CascadeStateStore)
def test_degrade_to_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
assert not store._degraded
store._degrade_to_fallback()
assert store._degraded
assert isinstance(store._fallback, InMemoryCascadeStateStore)
def test_increment_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
result = store.increment_interaction("s1")
assert result == 1
assert store._fallback.get_interaction("s1") == 1
def test_get_interaction_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
store._fallback.increment_interaction("s1")
assert store.get_interaction("s1") == 1
def test_set_depth_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
store.set_depth("s1", 3)
assert store._fallback.get_depth("s1") == 3
def test_get_depth_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
store._fallback.set_depth("s1", 5)
assert store.get_depth("s1") == 5
def test_reset_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
store._fallback.increment_interaction("s1")
store.reset("s1")
assert store._fallback.get_interaction("s1") == 0
def test_increment_redis_failure_degrades(self):
store = RedisCascadeStateStore(redis_url="redis://nonexistent:6379")
# Will fail to connect and degrade
result = store.increment_interaction("s1")
assert store._degraded
assert result >= 0 # Should return something (fallback or 0)
def test_custom_session_ttl(self):
store = RedisCascadeStateStore(
redis_url="redis://localhost:6379", session_ttl=3600
)
assert store._session_ttl == 3600
def test_default_session_ttl(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
assert store._session_ttl == 86400
def test_increment_with_mock_redis(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.incr.return_value = mock_pipeline
mock_pipeline.expire.return_value = mock_pipeline
mock_pipeline.execute.return_value = [1, True]
mock_redis.pipeline.return_value = mock_pipeline
store._sync_redis = mock_redis
result = store.increment_interaction("s1")
assert result == 1
mock_pipeline.incr.assert_called_once()
mock_pipeline.expire.assert_called_once()
def test_get_interaction_with_mock_redis(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_redis.get.return_value = "3"
store._sync_redis = mock_redis
result = store.get_interaction("s1")
assert result == 3
def test_get_interaction_redis_returns_none(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_redis.get.return_value = None
store._sync_redis = mock_redis
result = store.get_interaction("s1")
assert result == 0
def test_set_depth_with_mock_redis(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.set.return_value = mock_pipeline
mock_pipeline.expire.return_value = mock_pipeline
mock_pipeline.execute.return_value = [True, True]
mock_redis.pipeline.return_value = mock_pipeline
store._sync_redis = mock_redis
store.set_depth("s1", 3)
mock_pipeline.set.assert_called_once()
mock_pipeline.expire.assert_called_once()
def test_reset_with_mock_redis(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.delete.return_value = mock_pipeline
mock_pipeline.execute.return_value = [1, 1]
mock_redis.pipeline.return_value = mock_pipeline
store._sync_redis = mock_redis
store.reset("s1")
assert mock_pipeline.delete.call_count == 2
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
class TestCreateCascadeStateStore:
def test_memory_backend(self):
store = create_cascade_state_store(backend="memory")
assert isinstance(store, InMemoryCascadeStateStore)
def test_auto_backend_returns_store(self):
store = create_cascade_state_store(backend="auto")
assert isinstance(store, (InMemoryCascadeStateStore, RedisCascadeStateStore))
def test_redis_backend_returns_store(self):
store = create_cascade_state_store(backend="redis")
assert isinstance(store, (InMemoryCascadeStateStore, RedisCascadeStateStore))
def test_session_ttl_passed_to_redis(self):
store = create_cascade_state_store(
backend="redis", session_ttl=7200
)
if isinstance(store, RedisCascadeStateStore):
assert store._session_ttl == 7200
def test_session_ttl_passed_to_memory(self):
store = create_cascade_state_store(
backend="memory", session_ttl=7200
)
assert isinstance(store, InMemoryCascadeStateStore)
assert store._session_ttl == 7200