chore: checkpoint frontend workspace updates
This commit is contained in:
parent
90795a25e6
commit
4a8cec9dc2
84 changed files with 7300 additions and 669 deletions
96
.github/workflows/release.yml
vendored
96
.github/workflows/release.yml
vendored
|
|
@ -42,14 +42,32 @@ jobs:
|
|||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Verify Sentry release env
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
|
||||
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify Sentry source map upload
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
|
@ -282,14 +300,32 @@ jobs:
|
|||
node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}"
|
||||
fi
|
||||
|
||||
- name: Verify Sentry release env (macOS ${{ matrix.arch }})
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
|
||||
|
||||
- name: Build app (macOS ${{ matrix.arch }})
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify Sentry source map upload (macOS ${{ matrix.arch }})
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||
|
||||
- name: Verify packaged inputs (macOS ${{ matrix.arch }})
|
||||
run: |
|
||||
|
|
@ -381,14 +417,32 @@ jobs:
|
|||
node ./scripts/stage-runtime.mjs --platform win32-x64
|
||||
}
|
||||
|
||||
- name: Verify Sentry release env (Windows)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
|
||||
|
||||
- name: Build app (Windows)
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify Sentry source map upload (Windows)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||
|
||||
- name: Verify packaged inputs (Windows)
|
||||
shell: bash
|
||||
|
|
@ -483,14 +537,32 @@ jobs:
|
|||
node ./scripts/stage-runtime.mjs --platform linux-x64
|
||||
fi
|
||||
|
||||
- name: Verify Sentry release env (Linux)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
|
||||
|
||||
- name: Build app (Linux)
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify Sentry source map upload (Linux)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||
|
||||
- name: Verify packaged inputs (Linux)
|
||||
run: |
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -65,3 +65,4 @@ remotion/*
|
|||
|
||||
# Local reference captures
|
||||
/agent-teams-reference-fix-*.png
|
||||
/.tmp-*
|
||||
|
|
|
|||
|
|
@ -44,30 +44,42 @@ function nativeModuleStub(): Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// Sentry source map upload — only active in CI when SENTRY_AUTH_TOKEN is set.
|
||||
const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
|
||||
project: process.env.SENTRY_PROJECT ?? 'electron',
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
release: { name: `agent-teams-ai@${pkg.version}` },
|
||||
sourcemaps: {
|
||||
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'],
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []
|
||||
const sentrySourceMapTargets = {
|
||||
main: {
|
||||
assets: ['./dist-electron/main/**/*.{js,cjs,mjs,map}'],
|
||||
filesToDeleteAfterUpload: ['./dist-electron/main/**/*.map'],
|
||||
},
|
||||
renderer: {
|
||||
assets: ['./out/renderer/**/*.{js,cjs,mjs,map}'],
|
||||
filesToDeleteAfterUpload: ['./out/renderer/**/*.map'],
|
||||
},
|
||||
} as const
|
||||
|
||||
// Sentry source map upload - only active in CI when SENTRY_AUTH_TOKEN is set.
|
||||
function createSentryPlugins(target: keyof typeof sentrySourceMapTargets): Plugin[] {
|
||||
if (!process.env.SENTRY_AUTH_TOKEN) return []
|
||||
|
||||
return [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
|
||||
project: process.env.SENTRY_PROJECT ?? 'electron',
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
telemetry: false,
|
||||
release: { name: `agent-teams-ai@${pkg.version}` },
|
||||
sourcemaps: sentrySourceMapTargets[target],
|
||||
}) as Plugin,
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
nativeModuleStub(),
|
||||
...sentryPlugins,
|
||||
...createSentryPlugins('main'),
|
||||
],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
// Inject DSN at compile time — process.env.SENTRY_DSN is NOT available
|
||||
// Inject DSN at compile time - process.env.SENTRY_DSN is NOT available
|
||||
// at runtime in packaged Electron apps (only during CI build).
|
||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''),
|
||||
},
|
||||
|
|
@ -148,10 +160,14 @@ export default defineConfig({
|
|||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@radix-ui/react-compose-refs': resolve(
|
||||
__dirname,
|
||||
'src/renderer/vendor/radixComposeRefs.ts'
|
||||
),
|
||||
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts')
|
||||
}
|
||||
},
|
||||
plugins: [react(), ...sentryPlugins],
|
||||
plugins: [react(), ...createSentryPlugins('renderer')],
|
||||
build: {
|
||||
sourcemap: 'hidden',
|
||||
rollupOptions: {
|
||||
|
|
|
|||
|
|
@ -1259,17 +1259,17 @@
|
|||
|
||||
.cyber-feature-rail__reviewer-bubble {
|
||||
--reviewer-bubble-center-shift: 3px;
|
||||
--robot-bubble-position: absolute;
|
||||
--robot-bubble-min-width: 112px;
|
||||
--robot-bubble-max-width: 184px;
|
||||
--robot-bubble-min-height: 46px;
|
||||
--robot-bubble-font-size: 0.64rem;
|
||||
--robot-bubble-padding: 8px 14px 16px;
|
||||
|
||||
left: auto;
|
||||
top: auto;
|
||||
right: calc(var(--reviewer-robot-width) / 2);
|
||||
bottom: calc(100% + 10px);
|
||||
z-index: 6;
|
||||
width: max-content;
|
||||
max-width: 158px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: balance;
|
||||
transform: translateX(calc(50% + var(--reviewer-bubble-center-shift))) translate3d(0, 0, 0) rotate(-4deg);
|
||||
transform-origin: center bottom;
|
||||
animation: cyberFeatureReviewerSpeechFloat 2.8s ease-in-out 0.45s infinite;
|
||||
|
|
|
|||
99
landing/components/common/RobotSpeechBubble.vue
Normal file
99
landing/components/common/RobotSpeechBubble.vue
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<script setup lang="ts">
|
||||
type RobotSpeechBubbleTail = "down" | "right";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tail?: RobotSpeechBubbleTail;
|
||||
}>(), {
|
||||
tail: "down",
|
||||
});
|
||||
|
||||
const bubblePath = computed(() => {
|
||||
if (props.tail === "right") {
|
||||
return "M18 6H79C94 6 104 16 104 30C104 32 104 34 103 35L118 35L99 44C94 50 87 53 79 53H18C9 53 4 44 4 30C4 16 9 6 18 6Z";
|
||||
}
|
||||
|
||||
return "M18 6H76C94 6 108 16 108 30C108 44 94 52 78 52H65L76 66L48 52H18C9 52 4 43 4 29C4 15 9 6 18 6Z";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="robot-speech-bubble"
|
||||
:class="`robot-speech-bubble--tail-${tail}`"
|
||||
>
|
||||
<svg
|
||||
class="robot-speech-bubble__shape"
|
||||
viewBox="0 0 120 70"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
class="robot-speech-bubble__fill"
|
||||
:d="bubblePath"
|
||||
/>
|
||||
</svg>
|
||||
<span class="robot-speech-bubble__text">
|
||||
<slot />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.robot-speech-bubble {
|
||||
position: var(--robot-bubble-position, relative);
|
||||
z-index: var(--robot-bubble-z-index, auto);
|
||||
display: inline-grid;
|
||||
min-width: var(--robot-bubble-min-width, 86px);
|
||||
max-width: var(--robot-bubble-max-width, 184px);
|
||||
min-height: var(--robot-bubble-min-height, 42px);
|
||||
box-sizing: border-box;
|
||||
color: var(--robot-bubble-color, #07111d);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: var(--robot-bubble-font-size, 0.66rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
|
||||
pointer-events: none;
|
||||
filter:
|
||||
drop-shadow(0 3px 0 rgba(0, 0, 0, 0.18))
|
||||
drop-shadow(0 0 11px rgba(255, 215, 0, 0.16));
|
||||
}
|
||||
|
||||
.robot-speech-bubble__shape {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.robot-speech-bubble__fill {
|
||||
fill: var(--robot-bubble-fill, #fff09a);
|
||||
stroke: var(--robot-bubble-stroke, #050816);
|
||||
stroke-width: var(--robot-bubble-stroke-width, 4.8);
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.robot-speech-bubble__text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
align-self: center;
|
||||
justify-self: stretch;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding: var(--robot-bubble-padding, 8px 16px 16px);
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.robot-speech-bubble--tail-right .robot-speech-bubble__text {
|
||||
padding: var(--robot-bubble-padding, 8px 24px 8px 13px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -67,13 +67,13 @@ const reviewerBubbleText = computed(() => {
|
|||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="cyber-feature-bubble">
|
||||
<CyberHeroSpeechBubble
|
||||
<RobotSpeechBubble
|
||||
v-if="reviewerBubbleText"
|
||||
class="cyber-feature-rail__reviewer-bubble"
|
||||
role="reviewer"
|
||||
tail="down"
|
||||
>
|
||||
{{ reviewerBubbleText }}
|
||||
</CyberHeroSpeechBubble>
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<div class="cyber-feature-rail__reviewer-card cyber-panel">
|
||||
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>
|
||||
|
|
|
|||
|
|
@ -14,20 +14,9 @@ const docsHref = computed(() => {
|
|||
<template>
|
||||
<footer class="app-footer">
|
||||
<div class="app-footer__robot-stage">
|
||||
<span class="app-footer__robot-bubble">
|
||||
<svg
|
||||
class="app-footer__robot-bubble-shape"
|
||||
viewBox="0 0 92 62"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
class="app-footer__robot-bubble-fill"
|
||||
d="M18 5H58C73 5 84 14 84 27C84 40 73 47 59 47H52L61 58L39 47H18C9 47 4 38 4 26C4 14 9 5 18 5Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="app-footer__robot-bubble-text">{{ t('footer.robotBubble') }}</span>
|
||||
</span>
|
||||
<RobotSpeechBubble class="app-footer__robot-bubble" tail="down">
|
||||
{{ t('footer.robotBubble') }}
|
||||
</RobotSpeechBubble>
|
||||
<img
|
||||
class="app-footer__robot"
|
||||
:src="robotLeadLounge"
|
||||
|
|
@ -82,51 +71,17 @@ const docsHref = computed(() => {
|
|||
}
|
||||
|
||||
.app-footer__robot-bubble {
|
||||
position: absolute;
|
||||
--robot-bubble-position: absolute;
|
||||
--robot-bubble-min-width: 82px;
|
||||
--robot-bubble-max-width: 116px;
|
||||
--robot-bubble-min-height: 50px;
|
||||
--robot-bubble-font-size: 0.62rem;
|
||||
--robot-bubble-padding: 9px 13px 16px;
|
||||
|
||||
top: -28px;
|
||||
left: -18px;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
width: 72px;
|
||||
height: 49px;
|
||||
color: #07111d;
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: 72% 74%;
|
||||
filter:
|
||||
drop-shadow(0 3px 0 rgba(0, 0, 0, 0.18))
|
||||
drop-shadow(0 0 9px rgba(255, 215, 0, 0.14));
|
||||
}
|
||||
|
||||
.app-footer__robot-bubble-shape {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-footer__robot-bubble-fill {
|
||||
fill: #fff09a;
|
||||
stroke: #050816;
|
||||
stroke-width: 4.6;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.app-footer__robot-bubble-text {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
width: 54px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-footer__inner {
|
||||
|
|
|
|||
|
|
@ -317,12 +317,13 @@ function getStatusIcon(status: string): string {
|
|||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="comparison-robot-bubble">
|
||||
<span
|
||||
<RobotSpeechBubble
|
||||
v-if="showComparisonRobotBubble"
|
||||
class="comparison-table__robot-bubble"
|
||||
tail="right"
|
||||
>
|
||||
{{ t("comparison.robotBubble") }}
|
||||
</span>
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<img
|
||||
class="comparison-table__robot-image"
|
||||
|
|
@ -487,59 +488,20 @@ function getStatusIcon(status: string): string {
|
|||
}
|
||||
|
||||
.comparison-table__robot-bubble {
|
||||
position: absolute;
|
||||
--robot-bubble-position: absolute;
|
||||
--robot-bubble-min-width: 96px;
|
||||
--robot-bubble-max-width: 190px;
|
||||
--robot-bubble-min-height: 42px;
|
||||
--robot-bubble-font-size: 0.66rem;
|
||||
--robot-bubble-padding: 8px 26px 8px 13px;
|
||||
|
||||
top: 10px;
|
||||
right: calc(100% + 12px);
|
||||
z-index: 5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
color: #07111d;
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
background:
|
||||
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.88), rgba(255, 244, 168, 0.86) 66%, rgba(255, 215, 0, 0.84) 100%);
|
||||
border: 2px solid #050816;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 215, 0, 0.28),
|
||||
0 5px 0 rgba(0, 0, 0, 0.2),
|
||||
0 0 12px rgba(255, 215, 0, 0.14);
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
|
||||
transform: rotate(-5deg);
|
||||
transform-origin: right bottom;
|
||||
animation: comparisonRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
|
||||
}
|
||||
|
||||
.comparison-table__robot-bubble::before {
|
||||
position: absolute;
|
||||
top: 52%;
|
||||
right: -30px;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
content: "";
|
||||
background: #050816;
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.comparison-table__robot-bubble::after {
|
||||
position: absolute;
|
||||
top: 52%;
|
||||
right: -24px;
|
||||
width: 26px;
|
||||
height: 12px;
|
||||
content: "";
|
||||
background: rgba(255, 226, 78, 0.96);
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.comparison-robot-bubble-enter-active,
|
||||
.comparison-robot-bubble-leave-active {
|
||||
transition:
|
||||
|
|
|
|||
|
|
@ -314,12 +314,13 @@ const releaseDate = computed(() => {
|
|||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="download-robot-bubble">
|
||||
<span
|
||||
<RobotSpeechBubble
|
||||
v-if="showLinuxRobotMessage"
|
||||
class="download-section__card-robot-bubble"
|
||||
tail="right"
|
||||
>
|
||||
Готов начать!
|
||||
</span>
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<img
|
||||
class="download-section__card-robot"
|
||||
|
|
@ -617,60 +618,20 @@ const releaseDate = computed(() => {
|
|||
}
|
||||
|
||||
.download-section__card-robot-bubble {
|
||||
position: absolute;
|
||||
--robot-bubble-position: absolute;
|
||||
--robot-bubble-min-width: 98px;
|
||||
--robot-bubble-max-width: 170px;
|
||||
--robot-bubble-min-height: 42px;
|
||||
--robot-bubble-font-size: 0.66rem;
|
||||
--robot-bubble-padding: 8px 26px 8px 13px;
|
||||
|
||||
top: 12px;
|
||||
right: calc(100% - 18px);
|
||||
z-index: 5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
color: #0b1020;
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
background:
|
||||
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.84), rgba(255, 244, 168, 0.84) 66%, rgba(255, 215, 0, 0.82) 100%);
|
||||
border: 2px solid #050816;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 215, 0, 0.28),
|
||||
0 5px 0 rgba(0, 0, 0, 0.2),
|
||||
0 0 12px rgba(255, 215, 0, 0.14);
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
|
||||
pointer-events: none;
|
||||
transform: rotate(-5deg);
|
||||
transform-origin: right bottom;
|
||||
animation: downloadRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
|
||||
}
|
||||
|
||||
.download-section__card-robot-bubble::after {
|
||||
position: absolute;
|
||||
top: 52%;
|
||||
right: -28px;
|
||||
width: 30px;
|
||||
height: 12px;
|
||||
content: "";
|
||||
background: rgba(255, 226, 78, 0.96);
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.download-section__card-robot-bubble::before {
|
||||
position: absolute;
|
||||
top: 52%;
|
||||
right: -34px;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
content: "";
|
||||
background: #050816;
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.download-robot-bubble-enter-active,
|
||||
.download-robot-bubble-leave-active {
|
||||
transition:
|
||||
|
|
@ -970,9 +931,9 @@ const releaseDate = computed(() => {
|
|||
.download-section__card-robot-bubble {
|
||||
top: 8px;
|
||||
right: calc(100% - 14px);
|
||||
min-height: 28px;
|
||||
padding: 6px 9px;
|
||||
font-size: 0.6rem;
|
||||
--robot-bubble-min-width: 88px;
|
||||
--robot-bubble-font-size: 0.6rem;
|
||||
--robot-bubble-padding: 7px 23px 7px 11px;
|
||||
}
|
||||
|
||||
.download-section__card-robot {
|
||||
|
|
|
|||
131
scripts/ci/verify-sentry-release.cjs
Normal file
131
scripts/ci/verify-sentry-release.cjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const pkg = require(path.join(repoRoot, 'package.json'));
|
||||
|
||||
const REQUIRED_ENV = ['SENTRY_DSN', 'SENTRY_AUTH_TOKEN', 'SENTRY_ORG', 'SENTRY_PROJECT'];
|
||||
const OUTPUT_DIRS = ['dist-electron/main', 'out/renderer'];
|
||||
const SENTRY_DEBUG_ID_RE = /\/\/# debugId=[a-fA-F0-9-]+/;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[sentry-release] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function isTaggedRelease() {
|
||||
return /^refs\/tags\/v/.test(process.env.GITHUB_REF ?? '');
|
||||
}
|
||||
|
||||
function assertTaggedReleaseEnv() {
|
||||
if (!isTaggedRelease()) {
|
||||
console.log('[sentry-release] skipped: not a tag release');
|
||||
return false;
|
||||
}
|
||||
|
||||
const missing = REQUIRED_ENV.filter((name) => !String(process.env[name] ?? '').trim());
|
||||
if (missing.length > 0) {
|
||||
fail(`missing required env for source map upload: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!String(process.env.SENTRY_DSN).startsWith('https://')) {
|
||||
fail('SENTRY_DSN must be an https DSN');
|
||||
}
|
||||
|
||||
const tagVersion = String(process.env.GITHUB_REF).replace(/^refs\/tags\/v/, '');
|
||||
if (pkg.version !== tagVersion) {
|
||||
fail(`package version ${pkg.version} does not match release tag v${tagVersion}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function walkFiles(relativeDir) {
|
||||
const absoluteDir = path.join(repoRoot, relativeDir);
|
||||
if (!fs.existsSync(absoluteDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const stack = [absoluteDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function prebuild() {
|
||||
if (!assertTaggedReleaseEnv()) return;
|
||||
|
||||
console.log(
|
||||
`[sentry-release] prebuild ok: release=agent-teams-ai@${pkg.version}, project=${process.env.SENTRY_ORG}/${process.env.SENTRY_PROJECT}`
|
||||
);
|
||||
}
|
||||
|
||||
function postbuild() {
|
||||
if (!assertTaggedReleaseEnv()) return;
|
||||
|
||||
const jsFilesByOutputDir = new Map();
|
||||
for (const outputDir of OUTPUT_DIRS) {
|
||||
const jsFiles = walkFiles(outputDir).filter((file) => /\.(?:js|cjs|mjs)$/.test(file));
|
||||
if (jsFiles.length === 0) {
|
||||
fail(`no built JavaScript files found in ${outputDir}`);
|
||||
}
|
||||
jsFilesByOutputDir.set(outputDir, jsFiles);
|
||||
}
|
||||
|
||||
const jsFiles = [...jsFilesByOutputDir.values()].flat();
|
||||
if (jsFiles.length === 0) {
|
||||
fail(`no built JavaScript files found in ${OUTPUT_DIRS.join(', ')}`);
|
||||
}
|
||||
|
||||
const missingDebugIdDirs = [];
|
||||
for (const [outputDir, files] of jsFilesByOutputDir.entries()) {
|
||||
const hasDebugId = files.some((file) => SENTRY_DEBUG_ID_RE.test(fs.readFileSync(file, 'utf8')));
|
||||
if (!hasDebugId) {
|
||||
missingDebugIdDirs.push(outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDebugIdDirs.length > 0) {
|
||||
fail(
|
||||
[
|
||||
'Sentry debug IDs were not injected into built JavaScript artifacts',
|
||||
...missingDebugIdDirs.map((dir) => ` - ${dir}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
const mapFiles = OUTPUT_DIRS.flatMap(walkFiles).filter((file) => file.endsWith('.map'));
|
||||
if (mapFiles.length > 0) {
|
||||
fail(
|
||||
[
|
||||
'source maps still exist after build; expected Sentry upload to delete them',
|
||||
...mapFiles.slice(0, 20).map((file) => ` - ${path.relative(repoRoot, file)}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[sentry-release] postbuild ok: ${jsFiles.length} JS artifacts built, debug IDs were injected, and source maps were removed after upload`
|
||||
);
|
||||
}
|
||||
|
||||
const command = process.argv[2] ?? 'prebuild';
|
||||
if (command === 'prebuild') {
|
||||
prebuild();
|
||||
} else if (command === 'postbuild') {
|
||||
postbuild();
|
||||
} else {
|
||||
fail(`unknown command: ${command}`);
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ export async function listTmuxPaneRuntimeInfoForCurrentPlatform(
|
|||
return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
|
||||
}
|
||||
|
||||
export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
|
||||
export async function listRuntimeProcessTableForCurrentPlatform(): Promise<
|
||||
RuntimeProcessTableRow[]
|
||||
> {
|
||||
return runtimeCommandExecutor.listRuntimeProcesses();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export {
|
|||
isTmuxRuntimeReadyForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatformSync,
|
||||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listRuntimeProcessTableForCurrentPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
sendKeysToTmuxPaneForCurrentPlatform,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,21 @@ let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
|||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||
const STATUS_CACHE_TTL_MS = 5_000;
|
||||
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
|
||||
|
||||
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
|
||||
return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId);
|
||||
}
|
||||
|
||||
function getCachedStatusAuthenticatedProvider(
|
||||
providers: CliProviderStatus[]
|
||||
): CliProviderStatus | null {
|
||||
return (
|
||||
providers.find(
|
||||
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes CLI installer handlers with the service instance.
|
||||
|
|
@ -122,6 +137,13 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator' &&
|
||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasProvider = cachedStatus.value.providers.some(
|
||||
(provider) => provider.providerId === providerStatus.providerId
|
||||
);
|
||||
|
|
@ -130,13 +152,19 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo
|
|||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||
)
|
||||
: [...cachedStatus.value.providers, providerStatus];
|
||||
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||
const authenticatedProvider =
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator'
|
||||
? getCachedStatusAuthenticatedProvider(nextProviders)
|
||||
: (nextProviders.find((provider) => provider.authenticated) ?? null);
|
||||
|
||||
cachedStatus = {
|
||||
value: {
|
||||
...cachedStatus.value,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authLoggedIn:
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator'
|
||||
? authenticatedProvider !== null
|
||||
: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
},
|
||||
at: Date.now(),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ import {
|
|||
registerTerminalHandlers,
|
||||
removeTerminalHandlers,
|
||||
} from './terminal';
|
||||
import { registerTelemetryHandlers, removeTelemetryHandlers } from './telemetry';
|
||||
import { registerTmuxHandlers, removeTmuxHandlers } from './tmux';
|
||||
import {
|
||||
initializeUpdaterHandlers,
|
||||
|
|
@ -268,6 +269,7 @@ export function initializeIpcHandlers(
|
|||
registerWindowHandlers(ipcMain);
|
||||
registerRendererLogHandlers(ipcMain);
|
||||
registerScheduleHandlers(ipcMain);
|
||||
registerTelemetryHandlers(ipcMain);
|
||||
if (cliInstaller) {
|
||||
registerCliInstallerHandlers(ipcMain);
|
||||
}
|
||||
|
|
@ -315,6 +317,7 @@ export function removeIpcHandlers(): void {
|
|||
removeWindowHandlers(ipcMain);
|
||||
removeRendererLogHandlers(ipcMain);
|
||||
removeScheduleHandlers(ipcMain);
|
||||
removeTelemetryHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeOpenCodeRuntimeHandlers(ipcMain);
|
||||
removeCodexRuntimeHandlers(ipcMain);
|
||||
|
|
|
|||
24
src/main/ipc/telemetry.ts
Normal file
24
src/main/ipc/telemetry.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Telemetry IPC handlers.
|
||||
*
|
||||
* Only exposes Sentry-safe anonymous context. Raw app identity stays in main.
|
||||
*/
|
||||
|
||||
import { getCurrentSentryTelemetryContext } from '@main/sentry';
|
||||
import {
|
||||
TELEMETRY_GET_SENTRY_CONTEXT,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||
} from '@preload/constants/ipcChannels';
|
||||
|
||||
import type { SentryTelemetryContext } from '@main/sentry';
|
||||
import type { IpcMain } from 'electron';
|
||||
|
||||
export function registerTelemetryHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(TELEMETRY_GET_SENTRY_CONTEXT, async (): Promise<SentryTelemetryContext | null> => {
|
||||
return getCurrentSentryTelemetryContext();
|
||||
});
|
||||
}
|
||||
|
||||
export function removeTelemetryHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(TELEMETRY_GET_SENTRY_CONTEXT);
|
||||
}
|
||||
|
|
@ -15,22 +15,69 @@ import {
|
|||
ensureAgentTeamsClientIdentity,
|
||||
getSentryAnonymousUserId,
|
||||
} from '@main/services/identity/AgentTeamsIdentityStore';
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import {
|
||||
filterSafeSentryIntegrations,
|
||||
isValidDsn,
|
||||
redactSentryEvent,
|
||||
SENTRY_ENVIRONMENT,
|
||||
SENTRY_RELEASE,
|
||||
TRACES_SAMPLE_RATE,
|
||||
} from '@shared/utils/sentryConfig';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Module-level flag that `beforeSend` checks.
|
||||
// Updated by `syncTelemetryFlag()` once ConfigManager is ready.
|
||||
// Defaults to `true` so early crash reports are NOT silently dropped;
|
||||
// if the user later turns telemetry off, the flag flips to `false`.
|
||||
let telemetryAllowed = true;
|
||||
const CONFIG_FILENAME = 'agent-teams-config.json';
|
||||
const LEGACY_CONFIG_FILENAMES = [
|
||||
'claude-devtools-config.json',
|
||||
'claude-code-context-config.json',
|
||||
] as const;
|
||||
|
||||
export interface SentryTelemetryContext {
|
||||
userId: string;
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
function readTelemetryFlagFromConfig(configPath: string): boolean | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const general = (parsed as { general?: unknown }).general;
|
||||
if (typeof general !== 'object' || general === null || Array.isArray(general)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const telemetryEnabled = (general as { telemetryEnabled?: unknown }).telemetryEnabled;
|
||||
return typeof telemetryEnabled === 'boolean' ? telemetryEnabled : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readPersistedTelemetryEnabled(basePath = getClaudeBasePath()): boolean {
|
||||
const currentPath = path.join(basePath, CONFIG_FILENAME);
|
||||
if (fs.existsSync(currentPath)) {
|
||||
return readTelemetryFlagFromConfig(currentPath) ?? true;
|
||||
}
|
||||
|
||||
const legacyPaths = LEGACY_CONFIG_FILENAMES.map((filename) => path.join(basePath, filename));
|
||||
const readableLegacyPath =
|
||||
legacyPaths.find((candidatePath) => readTelemetryFlagFromConfig(candidatePath) !== null) ??
|
||||
legacyPaths.find((candidatePath) => fs.existsSync(candidatePath));
|
||||
|
||||
return readableLegacyPath ? (readTelemetryFlagFromConfig(readableLegacyPath) ?? true) : true;
|
||||
}
|
||||
|
||||
// Module-level flag that `beforeSend` checks. Read persisted config before init
|
||||
// so telemetry-disabled users do not start Sentry sessions on app startup.
|
||||
let telemetryAllowed = readPersistedTelemetryEnabled();
|
||||
let telemetryIdentitySyncToken = 0;
|
||||
|
||||
export function getSafeSentryTelemetryTags(
|
||||
|
|
@ -50,21 +97,29 @@ export function getSafeSentryTelemetryTags(
|
|||
*/
|
||||
export function syncTelemetryFlag(enabled: boolean): void {
|
||||
telemetryAllowed = enabled;
|
||||
if (!enabled) {
|
||||
telemetryIdentitySyncToken++;
|
||||
shutdownSentry();
|
||||
return;
|
||||
}
|
||||
|
||||
initializeSentryIfAllowed();
|
||||
void syncTelemetryIdentity();
|
||||
}
|
||||
|
||||
export function filterSentryEventForTelemetry(event: unknown): unknown {
|
||||
return telemetryAllowed ? event : null;
|
||||
return telemetryAllowed ? redactSentryEvent(event) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy Sentry import — safe in non-Electron environments
|
||||
// Lazy Sentry import - safe in non-Electron environments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SentryMainApi {
|
||||
init?: (options: SentryInitOptions) => void;
|
||||
setUser?: (user: { id: string } | null) => void;
|
||||
setTags?: (tags: Record<string, string>) => void;
|
||||
close?: (timeout?: number) => PromiseLike<boolean> | boolean;
|
||||
addBreadcrumb?: (breadcrumb: {
|
||||
category: string;
|
||||
message: string;
|
||||
|
|
@ -82,6 +137,9 @@ interface SentryInitOptions {
|
|||
sendDefaultPii: false;
|
||||
beforeSend: (event: unknown) => unknown;
|
||||
beforeSendTransaction: (event: unknown) => unknown;
|
||||
integrations: <TIntegration extends { name?: string }>(
|
||||
integrations: TIntegration[]
|
||||
) => TIntegration[];
|
||||
}
|
||||
|
||||
let Sentry: SentryMainApi | null = null;
|
||||
|
|
@ -98,6 +156,41 @@ function clearSentryUser(): void {
|
|||
Sentry.setUser?.(null);
|
||||
}
|
||||
|
||||
function shutdownSentry(): void {
|
||||
const sentry = Sentry;
|
||||
if (initialized && sentry) {
|
||||
sentry.setUser?.(null);
|
||||
try {
|
||||
void Promise.resolve(sentry.close?.(2000)).catch(() => undefined);
|
||||
} catch {
|
||||
// Best effort only. The telemetry gate still blocks later events.
|
||||
}
|
||||
}
|
||||
|
||||
initialized = false;
|
||||
Sentry = null;
|
||||
}
|
||||
|
||||
export async function getCurrentSentryTelemetryContext(): Promise<SentryTelemetryContext | null> {
|
||||
if (!telemetryAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await ensureAgentTeamsClientIdentity();
|
||||
if (!telemetryAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: getSentryAnonymousUserId(identity.clientId),
|
||||
tags: getSafeSentryTelemetryTags(identity.source),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTelemetryIdentity(): Promise<void> {
|
||||
const syncToken = ++telemetryIdentitySyncToken;
|
||||
if (!initialized || !Sentry) {
|
||||
|
|
@ -110,13 +203,18 @@ async function syncTelemetryIdentity(): Promise<void> {
|
|||
}
|
||||
|
||||
try {
|
||||
const identity = await ensureAgentTeamsClientIdentity();
|
||||
const context = await getCurrentSentryTelemetryContext();
|
||||
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.setUser?.({ id: getSentryAnonymousUserId(identity.clientId) });
|
||||
Sentry.setTags?.(getSafeSentryTelemetryTags(identity.source));
|
||||
if (!context) {
|
||||
clearSentryUser();
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.setUser?.({ id: context.userId });
|
||||
Sentry.setTags?.(context.tags);
|
||||
} catch {
|
||||
if (syncToken === telemetryIdentitySyncToken) {
|
||||
clearSentryUser();
|
||||
|
|
@ -124,13 +222,20 @@ async function syncTelemetryIdentity(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
function initializeSentryIfAllowed(): void {
|
||||
if (initialized || !telemetryAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
if (!isValidDsn(dsn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidDsn(dsn)) {
|
||||
try {
|
||||
// Dynamic import would be cleaner but top-level await is not available
|
||||
// in all contexts. require() is synchronous and works in both Electron
|
||||
// and Node.js — it simply throws in standalone mode where the electron
|
||||
// and Node.js - it simply throws in standalone mode where the electron
|
||||
// module is not resolvable.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
|
||||
Sentry = require('@sentry/electron/main') as SentryMainApi;
|
||||
|
|
@ -143,15 +248,21 @@ if (isValidDsn(dsn)) {
|
|||
|
||||
beforeSend: filterSentryEventForTelemetry,
|
||||
beforeSendTransaction: filterSentryEventForTelemetry,
|
||||
integrations: filterSafeSentryIntegrations,
|
||||
});
|
||||
initialized = true;
|
||||
void syncTelemetryIdentity();
|
||||
} catch {
|
||||
// @sentry/electron/main requires Electron runtime — not available in
|
||||
Sentry = null;
|
||||
initialized = false;
|
||||
// @sentry/electron/main requires Electron runtime - not available in
|
||||
// standalone (pure Node.js) mode. All exported helpers are no-ops when
|
||||
// initialized is false, so this is safe to swallow.
|
||||
}
|
||||
}
|
||||
|
||||
initializeSentryIfAllowed();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers (no-op when Sentry is not configured)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -160,10 +271,10 @@ if (isValidDsn(dsn)) {
|
|||
export function addMainBreadcrumb(
|
||||
category: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
_data?: Record<string, unknown>
|
||||
): void {
|
||||
if (!initialized) return;
|
||||
Sentry?.addBreadcrumb?.({ category, message, data, level: 'info' });
|
||||
Sentry?.addBreadcrumb?.({ category, message, level: 'info' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
|||
import {
|
||||
getCachedShellEnv,
|
||||
getShellPreferredHome,
|
||||
resolveInteractiveShellEnv,
|
||||
resolveInteractiveShellEnvBestEffort,
|
||||
} from '@main/utils/shellEnv';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -68,6 +68,47 @@ const GCS_BASE =
|
|||
'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases';
|
||||
|
||||
const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress';
|
||||
const FRONTEND_MULTIMODEL_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'opencode'];
|
||||
const FRONTEND_MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(
|
||||
FRONTEND_MULTIMODEL_PROVIDER_IDS
|
||||
);
|
||||
|
||||
function getProviderDisplayName(providerId: CliProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
return 'Anthropic';
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
|
||||
return FRONTEND_MULTIMODEL_PROVIDER_ID_SET.has(providerId);
|
||||
}
|
||||
|
||||
function getFrontendAuthenticatedProvider(
|
||||
providers: CliProviderStatus[]
|
||||
): CliProviderStatus | null {
|
||||
return (
|
||||
providers.find(
|
||||
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function hasFrontendAuthenticatedProvider(providers: CliProviderStatus[]): boolean {
|
||||
return providers.some(
|
||||
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
);
|
||||
}
|
||||
|
||||
function filterFrontendMultimodelProviders(providers: CliProviderStatus[]): CliProviderStatus[] {
|
||||
return providers.filter((provider) => isFrontendMultimodelProviderId(provider.providerId));
|
||||
}
|
||||
|
||||
/** Timeout for `claude --version` (ms) */
|
||||
const VERSION_TIMEOUT_MS = 10_000;
|
||||
|
|
@ -152,6 +193,7 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
providers: status.providers.map((provider) => ({
|
||||
...provider,
|
||||
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||
modelCatalogRefreshState: provider.modelCatalogRefreshState ?? 'idle',
|
||||
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
|
||||
detailMessage: provider.detailMessage ?? null,
|
||||
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
||||
|
|
@ -176,6 +218,26 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
};
|
||||
}
|
||||
|
||||
function mergeProviderStatusCatalogCache(
|
||||
incomingProvider: CliProviderStatus,
|
||||
currentProvider: CliProviderStatus
|
||||
): CliProviderStatus {
|
||||
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
|
||||
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
|
||||
|
||||
return {
|
||||
...incomingProvider,
|
||||
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
|
||||
modelCatalog,
|
||||
modelCatalogRefreshState:
|
||||
modelCatalog && incomingRefreshState !== 'error'
|
||||
? 'ready'
|
||||
: (incomingRefreshState ?? currentProvider.modelCatalogRefreshState ?? 'idle'),
|
||||
runtimeCapabilities:
|
||||
incomingProvider.runtimeCapabilities ?? currentProvider.runtimeCapabilities ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneProviderModelAvailability(
|
||||
modelAvailability: CliProviderModelAvailability[] | undefined
|
||||
): CliProviderModelAvailability[] {
|
||||
|
|
@ -485,27 +547,9 @@ export class CliInstallerService {
|
|||
const ui = getCliFlavorUiOptions(flavor);
|
||||
const providers =
|
||||
flavor === 'agent_teams_orchestrator'
|
||||
? (
|
||||
[
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
},
|
||||
{
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
},
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
},
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
...provider,
|
||||
? FRONTEND_MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
|
||||
providerId,
|
||||
displayName: getProviderDisplayName(providerId),
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
|
|
@ -514,7 +558,7 @@ export class CliInstallerService {
|
|||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: provider.providerId !== 'opencode',
|
||||
canLoginFromUi: providerId !== 'opencode',
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
|
|
@ -652,6 +696,13 @@ export class CliInstallerService {
|
|||
}
|
||||
|
||||
private updateLatestProviderStatus(providerStatus: CliProviderStatus): void {
|
||||
if (
|
||||
this.latestStatusSnapshot?.flavor === 'agent_teams_orchestrator' &&
|
||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
providerStatus.modelVerificationState !== 'verifying' &&
|
||||
(providerStatus.modelAvailability?.length ?? 0) <= 0
|
||||
|
|
@ -668,15 +719,17 @@ export class CliInstallerService {
|
|||
);
|
||||
const nextProviders = hasProvider
|
||||
? this.latestStatusSnapshot.providers.map((provider) =>
|
||||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||
provider.providerId === providerStatus.providerId
|
||||
? mergeProviderStatusCatalogCache(providerStatus, provider)
|
||||
: provider
|
||||
)
|
||||
: [...this.latestStatusSnapshot.providers, providerStatus];
|
||||
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||
const authenticatedProvider = getFrontendAuthenticatedProvider(nextProviders);
|
||||
|
||||
this.latestStatusSnapshot = {
|
||||
...this.latestStatusSnapshot,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authLoggedIn: hasFrontendAuthenticatedProvider(nextProviders),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -724,7 +777,7 @@ export class CliInstallerService {
|
|||
}
|
||||
|
||||
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
|
|
@ -744,14 +797,20 @@ export class CliInstallerService {
|
|||
|
||||
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
||||
binaryPath,
|
||||
providerId
|
||||
providerId,
|
||||
(hydratedProviderStatus) => {
|
||||
this.updateLatestProviderStatus(hydratedProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
}
|
||||
);
|
||||
this.updateLatestProviderStatus(providerStatus);
|
||||
return providerStatus;
|
||||
}
|
||||
|
||||
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
|
|
@ -813,7 +872,7 @@ export class CliInstallerService {
|
|||
diag: CliInstallerStatusRunDiag
|
||||
): Promise<void> {
|
||||
resetGatherDiag(diag);
|
||||
await resolveInteractiveShellEnv();
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
|
||||
const r = ref.current;
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
|
|
@ -952,6 +1011,7 @@ export class CliInstallerService {
|
|||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
modelVerificationState: 'idle',
|
||||
modelCatalogRefreshState: 'error',
|
||||
statusMessage: message,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
|
|
@ -979,17 +1039,18 @@ export class CliInstallerService {
|
|||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
||||
binaryPath,
|
||||
(providersSnapshot) => {
|
||||
result.providers = providersSnapshot;
|
||||
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
||||
result.providers = frontendProviders;
|
||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
result.authMethod =
|
||||
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
this.publishStatusSnapshot(result);
|
||||
}
|
||||
);
|
||||
result.providers = providers;
|
||||
result.authLoggedIn = providers.some((provider) => provider.authenticated);
|
||||
result.authMethod =
|
||||
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providers);
|
||||
result.providers = frontendProviders;
|
||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
result.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
this.publishStatusSnapshot(result);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
createDefaultCliExtensionCapabilities,
|
||||
|
|
@ -316,6 +316,7 @@ export interface OpenCodeRuntimeTranscriptLogMessage {
|
|||
}
|
||||
|
||||
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'];
|
||||
const DEFAULT_PROVIDER_STATUS_IDS: CliProviderId[] = ['anthropic', 'codex', 'opencode'];
|
||||
|
||||
function getProviderDisplayName(providerId: CliProviderId): string {
|
||||
switch (providerId) {
|
||||
|
|
@ -353,6 +354,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
|||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
modelCatalogRefreshState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
|
|
@ -582,6 +584,17 @@ function mapRuntimeProviderModelCatalog(
|
|||
};
|
||||
}
|
||||
|
||||
function getRuntimeModelCatalogRefreshState(
|
||||
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined,
|
||||
modelCatalog: CliProviderStatus['modelCatalog']
|
||||
): NonNullable<CliProviderStatus['modelCatalogRefreshState']> {
|
||||
if (modelCatalog) {
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
return runtimeStatus?.runtimeCapabilities?.modelCatalog?.dynamic === true ? 'loading' : 'idle';
|
||||
}
|
||||
|
||||
function mapRuntimeSubscriptionRateLimitWindow(
|
||||
window: RuntimeSubscriptionRateLimitWindowResponse | null | undefined
|
||||
): NonNullable<CliProviderSubscriptionRateLimitSnapshot['primary']> | null {
|
||||
|
|
@ -620,7 +633,64 @@ function mapRuntimeSubscriptionRateLimits(
|
|||
return primary || secondary ? { primary, secondary } : null;
|
||||
}
|
||||
|
||||
function mergeRuntimeCapabilitiesForCatalogHydration(
|
||||
live: CliProviderStatus['runtimeCapabilities'],
|
||||
hydrated: CliProviderStatus['runtimeCapabilities']
|
||||
): CliProviderStatus['runtimeCapabilities'] {
|
||||
if (!hydrated) {
|
||||
return live ?? null;
|
||||
}
|
||||
if (!live) {
|
||||
return hydrated;
|
||||
}
|
||||
return {
|
||||
...live,
|
||||
modelCatalog: hydrated.modelCatalog ?? live.modelCatalog,
|
||||
reasoningEffort: hydrated.reasoningEffort ?? live.reasoningEffort,
|
||||
fastMode: hydrated.fastMode ?? live.fastMode,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProviderCatalogFields(
|
||||
liveProvider: CliProviderStatus,
|
||||
hydratedProvider: CliProviderStatus
|
||||
): CliProviderStatus {
|
||||
const modelCatalog = hydratedProvider.modelCatalog ?? liveProvider.modelCatalog ?? null;
|
||||
return {
|
||||
...liveProvider,
|
||||
models: hydratedProvider.models.length > 0 ? hydratedProvider.models : liveProvider.models,
|
||||
modelCatalog,
|
||||
modelCatalogRefreshState: modelCatalog
|
||||
? 'ready'
|
||||
: hydratedProvider.modelCatalogRefreshState === 'error'
|
||||
? 'error'
|
||||
: liveProvider.modelCatalogRefreshState,
|
||||
runtimeCapabilities: mergeRuntimeCapabilitiesForCatalogHydration(
|
||||
liveProvider.runtimeCapabilities,
|
||||
hydratedProvider.runtimeCapabilities
|
||||
),
|
||||
subscriptionRateLimits:
|
||||
hydratedProvider.subscriptionRateLimits ?? liveProvider.subscriptionRateLimits ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export class ClaudeMultimodelBridgeService {
|
||||
private providerStatusHydrationGeneration = 0;
|
||||
|
||||
private readonly providerStatusHydrationGenerations = new Map<CliProviderId, number>();
|
||||
|
||||
private beginProviderStatusHydration(providerIds: readonly CliProviderId[]): number {
|
||||
const generation = ++this.providerStatusHydrationGeneration;
|
||||
for (const providerId of providerIds) {
|
||||
this.providerStatusHydrationGenerations.set(providerId, generation);
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
private isProviderStatusHydrationCurrent(providerId: CliProviderId, generation: number): boolean {
|
||||
return this.providerStatusHydrationGenerations.get(providerId) === generation;
|
||||
}
|
||||
|
||||
private async buildCliEnv(
|
||||
binaryPath: string
|
||||
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||
|
|
@ -658,6 +728,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
if (!runtimeStatus) {
|
||||
return provider;
|
||||
}
|
||||
const modelCatalog = mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog);
|
||||
|
||||
return {
|
||||
...provider,
|
||||
|
|
@ -700,7 +771,8 @@ export class ClaudeMultimodelBridgeService {
|
|||
detailMessage: diagnostic.detailMessage ?? null,
|
||||
})) ?? [],
|
||||
models: extractModelIds(runtimeStatus.models),
|
||||
modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog),
|
||||
modelCatalog,
|
||||
modelCatalogRefreshState: getRuntimeModelCatalogRefreshState(runtimeStatus, modelCatalog),
|
||||
subscriptionRateLimits: mapRuntimeSubscriptionRateLimits(
|
||||
providerId,
|
||||
runtimeStatus.authMethod,
|
||||
|
|
@ -774,9 +846,10 @@ export class ClaudeMultimodelBridgeService {
|
|||
}
|
||||
|
||||
private buildProviderStatusesSnapshot(
|
||||
providers: Map<CliProviderId, CliProviderStatus>
|
||||
providers: Map<CliProviderId, CliProviderStatus>,
|
||||
providerIds: readonly CliProviderId[] = ORDERED_PROVIDER_IDS
|
||||
): CliProviderStatus[] {
|
||||
return ORDERED_PROVIDER_IDS.map(
|
||||
return providerIds.map(
|
||||
(providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId)
|
||||
);
|
||||
}
|
||||
|
|
@ -785,59 +858,62 @@ export class ClaudeMultimodelBridgeService {
|
|||
binaryPath: string,
|
||||
providerId: CliProviderId,
|
||||
env: NodeJS.ProcessEnv,
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>,
|
||||
options: { summary?: boolean } = {}
|
||||
): Promise<CliProviderStatus> {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['runtime', 'status', '--json', '--provider', providerId],
|
||||
{
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||
env,
|
||||
}
|
||||
);
|
||||
const args = ['runtime', 'status', '--json', '--provider', providerId];
|
||||
if (options.summary) {
|
||||
args.push('--summary');
|
||||
}
|
||||
const { stdout } = await execCli(binaryPath, args, {
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||
env,
|
||||
});
|
||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
return providerConnectionService.enrichProviderStatus(
|
||||
this.applyConnectionIssue(
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
||||
connectionIssues
|
||||
)
|
||||
),
|
||||
{ hydrateModelCatalog: options.summary !== true }
|
||||
);
|
||||
}
|
||||
|
||||
private async getProviderStatusFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
providerId: CliProviderId,
|
||||
options: { summary?: boolean } = {}
|
||||
): Promise<CliProviderStatus> {
|
||||
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
||||
return this.getProviderStatusFromRuntimeStatusCommand(
|
||||
binaryPath,
|
||||
providerId,
|
||||
env,
|
||||
connectionIssues
|
||||
connectionIssues,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
private async getProviderStatusesFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void,
|
||||
options: { summary?: boolean; providerIds?: readonly CliProviderId[] } = {}
|
||||
): Promise<CliProviderStatus[] | null> {
|
||||
const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS;
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
createPendingProviderStatus(providerId),
|
||||
])
|
||||
providerIds.map((providerId) => [providerId, createPendingProviderStatus(providerId)])
|
||||
);
|
||||
const failures: { providerId: CliProviderId; error: unknown }[] = [];
|
||||
|
||||
await Promise.all(
|
||||
ORDERED_PROVIDER_IDS.map(async (providerId) => {
|
||||
providerIds.map(async (providerId) => {
|
||||
try {
|
||||
providers.set(
|
||||
providerId,
|
||||
await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId)
|
||||
await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId, options)
|
||||
);
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||
} catch (error) {
|
||||
failures.push({ providerId, error });
|
||||
}
|
||||
|
|
@ -845,10 +921,10 @@ export class ClaudeMultimodelBridgeService {
|
|||
);
|
||||
|
||||
if (failures.length === 0) {
|
||||
return this.buildProviderStatusesSnapshot(providers);
|
||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||
}
|
||||
|
||||
if (failures.length === ORDERED_PROVIDER_IDS.length) {
|
||||
if (failures.length === providerIds.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -861,8 +937,65 @@ export class ClaudeMultimodelBridgeService {
|
|||
for (const { providerId, error } of failures) {
|
||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
||||
}
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
|
||||
return this.buildProviderStatusesSnapshot(providers);
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||
}
|
||||
|
||||
private hydrateProviderCatalogs(
|
||||
binaryPath: string,
|
||||
liveProviders: CliProviderStatus[],
|
||||
generation: number,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): void {
|
||||
if (!onUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
liveProviders.map((provider) => [provider.providerId, provider])
|
||||
);
|
||||
const providerIds = liveProviders.map((provider) => provider.providerId);
|
||||
|
||||
for (const liveProvider of liveProviders) {
|
||||
if (liveProvider.runtimeCapabilities?.modelCatalog?.dynamic !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void this.getProviderStatusFromScopedRuntimeStatus(binaryPath, liveProvider.providerId)
|
||||
.then((hydratedProvider) => {
|
||||
if (!this.isProviderStatusHydrationCurrent(liveProvider.providerId, generation)) {
|
||||
return;
|
||||
}
|
||||
const currentProvider = providers.get(liveProvider.providerId);
|
||||
if (!currentProvider) {
|
||||
return;
|
||||
}
|
||||
providers.set(
|
||||
liveProvider.providerId,
|
||||
mergeProviderCatalogFields(currentProvider, hydratedProvider)
|
||||
);
|
||||
onUpdate(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!this.isProviderStatusHydrationCurrent(liveProvider.providerId, generation)) {
|
||||
return;
|
||||
}
|
||||
const currentProvider = providers.get(liveProvider.providerId);
|
||||
if (!currentProvider) {
|
||||
return;
|
||||
}
|
||||
providers.set(liveProvider.providerId, {
|
||||
...currentProvider,
|
||||
modelCatalogRefreshState: 'error',
|
||||
});
|
||||
logger.warn(
|
||||
`Provider catalog hydration failed for ${liveProvider.providerId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
onUpdate(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getOpenCodeVerifySnapshot(
|
||||
|
|
@ -956,22 +1089,54 @@ export class ClaudeMultimodelBridgeService {
|
|||
|
||||
async getProviderStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
providerId: CliProviderId,
|
||||
onCatalogUpdate?: (provider: CliProviderStatus) => void
|
||||
): Promise<CliProviderStatus> {
|
||||
await resolveInteractiveShellEnv();
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
|
||||
try {
|
||||
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
|
||||
const generation = this.beginProviderStatusHydration([providerId]);
|
||||
const provider = await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId, {
|
||||
summary: true,
|
||||
});
|
||||
if (provider.runtimeCapabilities?.modelCatalog?.dynamic === true && onCatalogUpdate) {
|
||||
void this.getProviderStatusFromScopedRuntimeStatus(binaryPath, provider.providerId)
|
||||
.then((hydratedProvider) => {
|
||||
if (!this.isProviderStatusHydrationCurrent(provider.providerId, generation)) {
|
||||
return;
|
||||
}
|
||||
onCatalogUpdate(mergeProviderCatalogFields(provider, hydratedProvider));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!this.isProviderStatusHydrationCurrent(provider.providerId, generation)) {
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
`Provider catalog hydration failed for ${provider.providerId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
onCatalogUpdate({
|
||||
...provider,
|
||||
modelCatalogRefreshState: 'error',
|
||||
});
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
} catch (error) {
|
||||
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||
logger.warn(
|
||||
`Provider-scoped runtime status unavailable for ${providerId}, falling back to full probe: ${
|
||||
`Provider-scoped summary runtime status unavailable for ${providerId}, falling back to full probe: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (providerId === 'gemini') {
|
||||
return this.buildGeminiStatus(binaryPath);
|
||||
}
|
||||
|
||||
const providers = await this.getProviderStatuses(binaryPath);
|
||||
return (
|
||||
providers.find((provider) => provider.providerId === providerId) ??
|
||||
|
|
@ -1129,16 +1294,39 @@ export class ClaudeMultimodelBridgeService {
|
|||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): Promise<CliProviderStatus[]> {
|
||||
await resolveInteractiveShellEnv();
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
|
||||
try {
|
||||
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(binaryPath, onUpdate);
|
||||
const generation = this.beginProviderStatusHydration(DEFAULT_PROVIDER_STATUS_IDS);
|
||||
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
|
||||
binaryPath,
|
||||
onUpdate,
|
||||
{ summary: true, providerIds: DEFAULT_PROVIDER_STATUS_IDS }
|
||||
);
|
||||
if (providers) {
|
||||
this.hydrateProviderCatalogs(binaryPath, providers, generation, onUpdate);
|
||||
return providers;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Provider-scoped summary runtime status unavailable, falling back to full probe: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
|
||||
binaryPath,
|
||||
onUpdate,
|
||||
{ providerIds: DEFAULT_PROVIDER_STATUS_IDS }
|
||||
);
|
||||
if (providers) {
|
||||
return providers;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Provider-scoped runtime status unavailable, falling back to full probe: ${
|
||||
`Provider-scoped full runtime status unavailable, falling back to legacy probes: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
|
|
@ -1155,7 +1343,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
const providers = await providerConnectionService.enrichProviderStatuses(
|
||||
this.applyConnectionIssues(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) =>
|
||||
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) =>
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
||||
),
|
||||
connectionIssues
|
||||
|
|
@ -1187,7 +1375,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
]);
|
||||
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => [
|
||||
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) => [
|
||||
providerId,
|
||||
createDefaultProviderStatus(providerId),
|
||||
])
|
||||
|
|
@ -1196,7 +1384,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
if (statusResult.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = extractJsonObject<ProviderStatusCommandResponse>(statusResult.value.stdout);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
|
||||
for (const providerId of DEFAULT_PROVIDER_STATUS_IDS) {
|
||||
const runtimeStatus = parsed.providers?.[providerId];
|
||||
if (!runtimeStatus) continue;
|
||||
providers.set(providerId, {
|
||||
|
|
@ -1226,7 +1414,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
}
|
||||
: null,
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
onUpdate?.(DEFAULT_PROVIDER_STATUS_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -1241,26 +1429,26 @@ export class ClaudeMultimodelBridgeService {
|
|||
? statusResult.reason.message
|
||||
: String(statusResult.reason);
|
||||
logger.warn(`Provider auth status unavailable: ${message}`);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS) {
|
||||
for (const providerId of DEFAULT_PROVIDER_STATUS_IDS) {
|
||||
providers.set(providerId, {
|
||||
...providers.get(providerId)!,
|
||||
statusMessage: 'Provider status not supported by current claude-multimodel build',
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
onUpdate?.(DEFAULT_PROVIDER_STATUS_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
}
|
||||
|
||||
if (modelsResult.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelsResult.value.stdout);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
|
||||
for (const providerId of DEFAULT_PROVIDER_STATUS_IDS) {
|
||||
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
|
||||
if (runtimeModels.length === 0) continue;
|
||||
providers.set(providerId, {
|
||||
...providers.get(providerId)!,
|
||||
models: runtimeModels,
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
onUpdate?.(DEFAULT_PROVIDER_STATUS_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -1271,12 +1459,9 @@ export class ClaudeMultimodelBridgeService {
|
|||
}
|
||||
}
|
||||
|
||||
providers.set('gemini', await this.buildGeminiStatus(binaryPath));
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
|
||||
const enrichedProviders = await providerConnectionService.enrichProviderStatuses(
|
||||
this.applyConnectionIssues(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!),
|
||||
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) => providers.get(providerId)!),
|
||||
connectionIssues
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -107,6 +107,10 @@ type AnthropicApiKeyVerifier = (
|
|||
baseUrl?: string | null
|
||||
) => Promise<AnthropicApiKeyVerificationResult>;
|
||||
|
||||
interface ProviderStatusEnrichmentOptions {
|
||||
hydrateModelCatalog?: boolean;
|
||||
}
|
||||
|
||||
function hashCredentialForCache(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
|
@ -698,7 +702,10 @@ export class ProviderConnectionService {
|
|||
return [];
|
||||
}
|
||||
|
||||
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
|
||||
async enrichProviderStatus(
|
||||
provider: CliProviderStatus,
|
||||
options: ProviderStatusEnrichmentOptions = {}
|
||||
): Promise<CliProviderStatus> {
|
||||
const withConnection = {
|
||||
...provider,
|
||||
connection: await this.getConnectionInfo(provider.providerId),
|
||||
|
|
@ -713,6 +720,13 @@ export class ProviderConnectionService {
|
|||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
options.hydrateModelCatalog === false &&
|
||||
!isUsableCodexModelCatalog(withConnection.modelCatalog)
|
||||
) {
|
||||
return withConnection;
|
||||
}
|
||||
|
||||
const orchestratorCatalog = isUsableCodexModelCatalog(withConnection.modelCatalog)
|
||||
? withConnection.modelCatalog
|
||||
: null;
|
||||
|
|
|
|||
|
|
@ -926,6 +926,9 @@ export function snapshotToMemberSpawnStatuses(
|
|||
bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed,
|
||||
hardFailure: skippedForLaunch ? false : entry.hardFailure,
|
||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
||||
bootstrapEvidenceSource: entry.bootstrapEvidenceSource,
|
||||
bootstrapMode: entry.bootstrapMode,
|
||||
appManagedBootstrapCandidate: entry.appManagedBootstrapCandidate,
|
||||
livenessKind: entry.livenessKind,
|
||||
runtimeDiagnostic: entry.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
|
||||
|
|
|
|||
|
|
@ -438,16 +438,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
|
||||
const memberKeysWithRecentErrors = new Set<string>();
|
||||
for (const [memberKey, records] of recordsByMember) {
|
||||
if (
|
||||
records.some((record) => {
|
||||
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
|
||||
return (
|
||||
isPotentialOpenCodeRuntimeDeliveryError(record) &&
|
||||
Number.isFinite(observedAt) &&
|
||||
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
|
||||
);
|
||||
})
|
||||
) {
|
||||
if (records.some((record) => this.isOpenCodeDeliveryAdvisoryCandidate(record, now))) {
|
||||
memberKeysWithRecentErrors.add(memberKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -509,12 +500,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
);
|
||||
const latestSuccess = ordered.find(isTerminalSuccessfulOpenCodeDeliveryRecord);
|
||||
const latestError = ordered.find((record) => {
|
||||
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
|
||||
return (
|
||||
isPotentialOpenCodeRuntimeDeliveryError(record) &&
|
||||
Number.isFinite(observedAt) &&
|
||||
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
|
||||
);
|
||||
return this.isOpenCodeDeliveryAdvisoryCandidate(record, now);
|
||||
});
|
||||
if (!latestError) {
|
||||
return null;
|
||||
|
|
@ -540,14 +526,87 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
if (!message || !decision.observedAt) {
|
||||
return null;
|
||||
}
|
||||
const retryWindow = this.extractOpenCodeDeliveryRetryWindow(latestError, now);
|
||||
return {
|
||||
kind: 'api_error',
|
||||
observedAt: decision.observedAt,
|
||||
reasonCode: decision.reasonCode,
|
||||
message,
|
||||
...(retryWindow ? retryWindow : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private extractOpenCodeDeliveryRetryWindow(
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
now: number
|
||||
): Pick<MemberRuntimeAdvisory, 'retryUntil' | 'retryDelayMs'> | null {
|
||||
const candidates = [
|
||||
...record.diagnostics.slice().reverse(),
|
||||
record.lastReason,
|
||||
record.nextAttemptAt,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const retryAt = this.parseOpenCodeRetryAt(candidate);
|
||||
if (!retryAt || retryAt <= now) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
retryUntil: new Date(retryAt).toISOString(),
|
||||
retryDelayMs: retryAt - now,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseOpenCodeRetryAt(value: string | null | undefined): number | null {
|
||||
const text = value?.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const lowerText = text.toLowerCase();
|
||||
const nextMarker = 'next=';
|
||||
const tokenStart = lowerText.indexOf(nextMarker);
|
||||
const valueStart = tokenStart >= 0 ? tokenStart + nextMarker.length : 0;
|
||||
let valueEnd = valueStart;
|
||||
while (valueEnd < text.length) {
|
||||
const char = text[valueEnd];
|
||||
if (
|
||||
char === ' ' ||
|
||||
char === '\t' ||
|
||||
char === '\n' ||
|
||||
char === '\r' ||
|
||||
char === ',' ||
|
||||
char === ';'
|
||||
) {
|
||||
break;
|
||||
}
|
||||
valueEnd += 1;
|
||||
}
|
||||
let cleaned = text.slice(valueStart, valueEnd);
|
||||
while (cleaned.endsWith('.') || cleaned.endsWith(')') || cleaned.endsWith(']')) {
|
||||
cleaned = cleaned.slice(0, -1);
|
||||
}
|
||||
const parsed = Date.parse(cleaned);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private isOpenCodeDeliveryAdvisoryCandidate(
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
now: number
|
||||
): boolean {
|
||||
if (!isPotentialOpenCodeRuntimeDeliveryError(record)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!isTerminalSuccessfulOpenCodeDeliveryRecord(record) &&
|
||||
record.status !== 'failed_terminal'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
|
||||
return Number.isFinite(observedAt) && now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS;
|
||||
}
|
||||
|
||||
private async findRecentMemberAdvisoriesFromBatchRefs(
|
||||
teamName: string,
|
||||
memberNames: readonly string[]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
import {
|
||||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listRuntimeProcessTableForCurrentPlatform,
|
||||
type RuntimeProcessTableRow,
|
||||
} from '@features/tmux-installer/main';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
|
|
@ -62,7 +62,7 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
|||
|
||||
const rows = await (
|
||||
options.listProcessRows ??
|
||||
(platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessesForCurrentTmuxPlatform)
|
||||
(platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessTableForCurrentPlatform)
|
||||
)();
|
||||
const excludePids = options.excludePids ?? new Set<number>();
|
||||
const requiredDetailsMarkers = options.requiredDetailsMarkers ?? [];
|
||||
|
|
@ -99,7 +99,8 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
|||
const isManagedByWindowsCommand =
|
||||
platform === 'win32' && isAppManagedWindowsOpenCodeServeCommand(row.command);
|
||||
const isManaged =
|
||||
isManagedByWindowsCommand || Boolean(details && isManagedOpenCodeServeProcessDetails(details));
|
||||
isManagedByWindowsCommand ||
|
||||
Boolean(details && isManagedOpenCodeServeProcessDetails(details));
|
||||
const hasRequiredDetailsMarkers =
|
||||
requiredDetailsMarkers.length === 0 ||
|
||||
Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers));
|
||||
|
|
|
|||
|
|
@ -90,6 +90,15 @@ export function isTerminalSuccessfulOpenCodeDeliveryRecord(
|
|||
export function isPotentialOpenCodeRuntimeDeliveryError(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): boolean {
|
||||
const terminalSuccess =
|
||||
record.status === 'responded' &&
|
||||
Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId);
|
||||
if (
|
||||
!terminalSuccess &&
|
||||
isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (record.status === 'failed_terminal') {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boo
|
|||
export function selectOpenCodeRuntimeDeliveryReason(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): string | null {
|
||||
const candidates = [...record.diagnostics.slice().reverse(), record.lastReason];
|
||||
const candidates = [...record.diagnostics.slice().reverse(), record.lastReason].filter(
|
||||
(diagnostic) => !isInformationalOpenCodeRuntimeDeliveryDiagnostic(diagnostic)
|
||||
);
|
||||
const selected = selectRuntimeDiagnosticClassification(candidates);
|
||||
|
||||
if (selected && !selected.generic && selected.normalizedMessage) {
|
||||
|
|
@ -33,6 +35,19 @@ export function selectOpenCodeRuntimeDeliveryReason(
|
|||
return selected ? 'OpenCode runtime delivery did not complete.' : null;
|
||||
}
|
||||
|
||||
function isInformationalOpenCodeRuntimeDeliveryDiagnostic(
|
||||
message: string | null | undefined
|
||||
): boolean {
|
||||
const normalized = message?.trim().toLowerCase();
|
||||
return (
|
||||
normalized === 'opencode app mcp is connected for message delivery.' ||
|
||||
normalized ===
|
||||
'opencode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.' ||
|
||||
normalized === 'opencode session status busy' ||
|
||||
normalized === 'opencode_delivery_response_pending'
|
||||
);
|
||||
}
|
||||
|
||||
export function isActionRequiredOpenCodeRuntimeDeliveryReason(
|
||||
message: string | null | undefined
|
||||
): boolean {
|
||||
|
|
|
|||
|
|
@ -43,9 +43,12 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
|
|||
'capacity exceeded',
|
||||
'quota exceeded',
|
||||
'quota exhausted',
|
||||
'usage exceeded',
|
||||
'free usage exceeded',
|
||||
'insufficient credits',
|
||||
'key limit exceeded',
|
||||
'total limit',
|
||||
'subscribe to go',
|
||||
],
|
||||
priority: 95,
|
||||
actionRequired: true,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
|
|||
/** Main -> renderer startup progress update */
|
||||
export const APP_STARTUP_PROGRESS = 'appStartup:progress';
|
||||
|
||||
// =============================================================================
|
||||
// Telemetry Channels
|
||||
// =============================================================================
|
||||
|
||||
/** Get Sentry-safe anonymous telemetry context */
|
||||
export const TELEMETRY_GET_SENTRY_CONTEXT = 'telemetry:getSentryContext';
|
||||
|
||||
// =============================================================================
|
||||
// Config API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ import {
|
|||
TEAM_UPDATE_TASK_OWNER,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
TEAM_VALIDATE_CLI_ARGS,
|
||||
TELEMETRY_GET_SENTRY_CONTEXT,
|
||||
TERMINAL_DATA,
|
||||
TERMINAL_EXIT,
|
||||
TERMINAL_KILL,
|
||||
|
|
@ -495,6 +496,9 @@ const electronAPI: ElectronAPI = {
|
|||
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
||||
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
||||
memberLogStream: createMemberLogStreamBridge(),
|
||||
telemetry: {
|
||||
getSentryContext: () => ipcRenderer.invoke(TELEMETRY_GET_SENTRY_CONTEXT),
|
||||
},
|
||||
startup: {
|
||||
getStatus: () => ipcRenderer.invoke(APP_STARTUP_GET_STATUS) as Promise<AppStartupStatus>,
|
||||
onProgress: (callback: (status: AppStartupStatus) => void): (() => void) => {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
private eventSource: EventSource | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
|
||||
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||
telemetry = {
|
||||
getSentryContext: async () => null,
|
||||
};
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
|
|
|
|||
|
|
@ -855,6 +855,9 @@ const InstalledBanner = ({
|
|||
isProviderCardLoading(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending) ||
|
||||
maskNegativeBootstrapState;
|
||||
const anthropicRateLimitsLoading =
|
||||
provider.providerId === 'anthropic' &&
|
||||
(anthropicRateLimitsRefreshing || provider.modelCatalogRefreshState === 'loading');
|
||||
const showRateLimitSkeleton =
|
||||
(showSkeleton &&
|
||||
shouldShowDashboardRateLimitSkeleton({
|
||||
|
|
@ -865,8 +868,9 @@ const InstalledBanner = ({
|
|||
(isSubscriptionRateLimitMode &&
|
||||
!hasDashboardRateLimits &&
|
||||
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
|
||||
(provider.providerId === 'anthropic' && anthropicRateLimitsRefreshing)));
|
||||
anthropicRateLimitsLoading));
|
||||
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
|
||||
const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading';
|
||||
const hasDetailContent = Boolean(
|
||||
(provider.backend?.label && !runtimeSummary) ||
|
||||
runtimeSummary ||
|
||||
|
|
@ -934,7 +938,10 @@ const InstalledBanner = ({
|
|||
) : null}
|
||||
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
|
||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||
{provider.models.length === 0 && (
|
||||
{provider.models.length === 0 && modelCatalogLoading ? (
|
||||
<span>Loading models...</span>
|
||||
) : null}
|
||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
|
|
@ -249,10 +250,17 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
: loadingCliStatus,
|
||||
[codexAccount.snapshot, loadingCliStatus]
|
||||
);
|
||||
const visibleEffectiveProviders = useMemo(
|
||||
() => filterMainScreenCliProviders(effectiveCliStatus?.providers ?? []),
|
||||
[effectiveCliStatus?.providers]
|
||||
);
|
||||
const loadingCliProviderMap = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider])
|
||||
filterMainScreenCliProviders(loadingCliStatus?.providers ?? []).map((provider) => [
|
||||
provider.providerId,
|
||||
provider,
|
||||
])
|
||||
),
|
||||
[loadingCliStatus?.providers]
|
||||
);
|
||||
|
|
@ -485,9 +493,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
{effectiveCliStatus.providers.length > 0 && (
|
||||
{visibleEffectiveProviders.length > 0 && (
|
||||
<div className="ml-6 mt-3 space-y-2">
|
||||
{effectiveCliStatus.providers.map((provider) => (
|
||||
{visibleEffectiveProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.providerId}
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
|
||||
|
|
@ -515,6 +523,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
const statusText = effectiveShowSkeleton
|
||||
? 'Checking...'
|
||||
: formatProviderStatusText(provider);
|
||||
const modelCatalogLoading =
|
||||
provider.modelCatalogRefreshState === 'loading';
|
||||
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
||||
const credentialSummary = getProviderCredentialSummary(provider);
|
||||
const disconnectAction = getProviderDisconnectAction(provider);
|
||||
|
|
@ -575,7 +585,10 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
<span>{connectionModeSummary}</span>
|
||||
) : null}
|
||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||
{provider.models.length === 0 && (
|
||||
{provider.models.length === 0 && modelCatalogLoading ? (
|
||||
<span>Loading models...</span>
|
||||
) : null}
|
||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
|
||||
|
|
@ -7,6 +8,7 @@ import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { markTaskUnread } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
import {
|
||||
|
|
@ -16,6 +18,7 @@ import {
|
|||
NO_PROJECT_KEY,
|
||||
sortTasksByFreshness,
|
||||
} from '@renderer/utils/taskGrouping';
|
||||
import { resolveTeamStatus } from '@renderer/utils/teamListStatus';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
Archive,
|
||||
|
|
@ -191,6 +194,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
viewMode,
|
||||
repositoryGroups,
|
||||
teams,
|
||||
provisioningRuns,
|
||||
currentProvisioningRunIdByTeam,
|
||||
leadActivityByTeam,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
globalTasks: s.globalTasks,
|
||||
|
|
@ -202,6 +208,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
viewMode: s.viewMode,
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
teams: s.teams,
|
||||
provisioningRuns: s.provisioningRuns,
|
||||
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
|
||||
leadActivityByTeam: s.leadActivityByTeam,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -217,6 +226,8 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
||||
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||
const [aliveTeamsInitialized, setAliveTeamsInitialized] = useState(false);
|
||||
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
|
|
@ -224,6 +235,21 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
const hasFetchedRef = useRef(false);
|
||||
const readState = useReadStateSnapshot();
|
||||
const taskLocalState = useTaskLocalState();
|
||||
const electronMode = isElectronMode();
|
||||
|
||||
const provisioningState = useMemo(
|
||||
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
||||
[currentProvisioningRunIdByTeam, provisioningRuns]
|
||||
);
|
||||
|
||||
const fetchAliveTeams = useCallback(async (): Promise<string[] | null> => {
|
||||
if (!electronMode || !api.teams?.aliveList) return null;
|
||||
try {
|
||||
return await api.teams.aliveList();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [electronMode]);
|
||||
|
||||
// --- New-task animation tracking (same pattern as ChatHistory) ---
|
||||
const knownTaskIdsRef = useRef<Set<string>>(new Set());
|
||||
|
|
@ -262,6 +288,70 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
[newTaskIds]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchAliveTeams().then((list) => {
|
||||
if (!cancelled && list) {
|
||||
setAliveTeams(list);
|
||||
setAliveTeamsInitialized(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchAliveTeams, teams]);
|
||||
|
||||
const readyProgressRefreshKey = useMemo(() => {
|
||||
return Object.entries(currentProvisioningRunIdByTeam)
|
||||
.map(([teamName, runId]) => {
|
||||
if (!runId) return null;
|
||||
const progress = provisioningRuns[runId];
|
||||
return progress?.state === 'ready'
|
||||
? `${teamName}:${progress.runId}:${progress.updatedAt}`
|
||||
: null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.join('|');
|
||||
}, [currentProvisioningRunIdByTeam, provisioningRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyProgressRefreshKey) return;
|
||||
let cancelled = false;
|
||||
void fetchAliveTeams().then((list) => {
|
||||
if (!cancelled && list) {
|
||||
setAliveTeams(list);
|
||||
setAliveTeamsInitialized(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchAliveTeams, readyProgressRefreshKey]);
|
||||
|
||||
const offlineTeamNames = useMemo(() => {
|
||||
const result = new Set<string>();
|
||||
if (aliveTeamsInitialized) {
|
||||
for (const team of teams) {
|
||||
const status = resolveTeamStatus(
|
||||
team,
|
||||
team.teamName,
|
||||
aliveTeams,
|
||||
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
|
||||
leadActivityByTeam
|
||||
);
|
||||
if (status === 'offline') {
|
||||
result.add(team.teamName);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [teamName, activity] of Object.entries(leadActivityByTeam)) {
|
||||
if (activity === 'offline') {
|
||||
result.add(teamName);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [aliveTeams, aliveTeamsInitialized, leadActivityByTeam, provisioningState, teams]);
|
||||
|
||||
const setGroupingMode = (mode: TaskGroupingMode): void => {
|
||||
setGroupingModeState(mode);
|
||||
saveGroupingMode(mode);
|
||||
|
|
@ -561,6 +651,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
<SidebarTaskItem
|
||||
task={task}
|
||||
showTeamName
|
||||
teamOffline={offlineTeamNames.has(task.teamName)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
@ -655,6 +746,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
<SidebarTaskItem
|
||||
task={task}
|
||||
showTeamName
|
||||
teamOffline={offlineTeamNames.has(task.teamName)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
@ -742,6 +834,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
task={task}
|
||||
hideTeamName
|
||||
hideProjectName
|
||||
teamOffline={offlineTeamNames.has(task.teamName)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
@ -848,6 +941,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
<AnimatedHeightReveal animate={isNewTask(task)}>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
teamOffline={offlineTeamNames.has(task.teamName)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ interface SidebarTaskItemProps {
|
|||
hideTeamName?: boolean;
|
||||
hideProjectName?: boolean;
|
||||
showTeamName?: boolean;
|
||||
/** Pauses the in-progress spinner when the parent team is offline. */
|
||||
teamOffline?: boolean;
|
||||
/** The composite key "teamName:taskId" of the task being renamed, or null */
|
||||
renamingKey?: string | null;
|
||||
/** Called when rename is completed with Enter or blur */
|
||||
|
|
@ -80,6 +82,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
hideTeamName,
|
||||
hideProjectName,
|
||||
showTeamName,
|
||||
teamOffline = false,
|
||||
renamingKey,
|
||||
onRenameComplete,
|
||||
onRenameCancel,
|
||||
|
|
@ -120,10 +123,11 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
||||
: (statusConfig[task.status] ?? statusConfig.pending);
|
||||
const StatusIcon = cfg.icon;
|
||||
const shouldAnimateStatusIcon = cfg.label === 'in progress' && !teamOffline;
|
||||
const statusIconClassName = cn(
|
||||
'size-3 shrink-0',
|
||||
cfg.color,
|
||||
cfg.label === 'in progress' && 'animate-spin'
|
||||
shouldAnimateStatusIcon && 'animate-spin'
|
||||
);
|
||||
const updatedLabel = formatUpdatedLabel(task);
|
||||
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ interface RenderedTeamChangeSummary {
|
|||
}
|
||||
|
||||
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
|
||||
const COMPACT_HIDDEN_INTERVAL_SCOPE_WARNINGS = new Set([
|
||||
'Task boundaries missing - scoped by workIntervals timestamps.',
|
||||
'Task start boundary missing - scoped by persisted workIntervals timestamps.',
|
||||
]);
|
||||
|
||||
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
|
||||
if (!Array.isArray(changeSet?.files)) {
|
||||
|
|
@ -111,6 +115,23 @@ function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefi
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function isWorkIntervalScopedFileChange(changeSet: TaskChangeSetV2): boolean {
|
||||
const reason = changeSet.scope?.confidence?.reason;
|
||||
return (
|
||||
getChangeSetFiles(changeSet).length > 0 &&
|
||||
changeSet.confidence === 'medium' &&
|
||||
typeof reason === 'string' &&
|
||||
reason.toLowerCase().includes('workinterval')
|
||||
);
|
||||
}
|
||||
|
||||
function shouldHideCompactDiagnostic(changeSet: TaskChangeSetV2, message: string): boolean {
|
||||
return (
|
||||
isWorkIntervalScopedFileChange(changeSet) &&
|
||||
COMPACT_HIDDEN_INTERVAL_SCOPE_WARNINGS.has(message.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
||||
const status = classifyTaskChangeReviewability(changeSet);
|
||||
if (status.reviewability === 'unknown' || status.reviewability === 'none') {
|
||||
|
|
@ -120,7 +141,13 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
|||
status.diagnostics.length > 0
|
||||
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||
: getChangeSetWarnings(changeSet);
|
||||
return [...new Set(messages.filter((message) => message.trim().length > 0))];
|
||||
return [
|
||||
...new Set(
|
||||
messages.filter(
|
||||
(message) => message.trim().length > 0 && !shouldHideCompactDiagnostic(changeSet, message)
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const TeamChangesSection = memo(function TeamChangesSection({
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
});
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
||||
const [runtimeTelemetryPreviewVisible, setRuntimeTelemetryPreviewVisible] = useState(false);
|
||||
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
||||
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
||||
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
|
||||
|
|
@ -2877,20 +2878,35 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
icon={<Users size={14} />}
|
||||
badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
|
||||
defaultOpen
|
||||
afterBadge={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddMemberDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserPlus size={12} />
|
||||
Add
|
||||
</Button>
|
||||
}
|
||||
action={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddMemberDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserPlus size={12} />
|
||||
Member
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 pr-3 text-[11px] font-medium leading-none text-[var(--color-text-muted)] transition-opacity duration-150',
|
||||
runtimeTelemetryPreviewVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(34,197,94,0.3)]" />
|
||||
Memory
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-blue-500 shadow-[0_0_6px_rgba(59,130,246,0.3)]" />
|
||||
CPU
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
contentWrapperClassName="-mx-[calc(1rem-5px)] w-[calc(100%+2rem-10px)]"
|
||||
|
|
@ -2907,6 +2923,8 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
isTeamAlive={data.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
launchParams={launchParams}
|
||||
runtimeTelemetryVisible={runtimeTelemetryPreviewVisible}
|
||||
onRuntimeTelemetryHoverChange={setRuntimeTelemetryPreviewVisible}
|
||||
onMemberClick={handleSelectMember}
|
||||
onSendMessage={handleSendMessageToMember}
|
||||
onAssignTask={handleAssignTaskToMember}
|
||||
|
|
|
|||
|
|
@ -234,6 +234,40 @@ function lowConfidenceFileResponse(): TeamTaskChangeSummariesResponse {
|
|||
});
|
||||
}
|
||||
|
||||
function intervalScopedFileResponse(): TeamTaskChangeSummariesResponse {
|
||||
return response({
|
||||
...changeSet(),
|
||||
confidence: 'medium',
|
||||
files: [
|
||||
fileChange({
|
||||
filePath: '/repo/791/calculator.js',
|
||||
relativePath: '791/calculator.js',
|
||||
}),
|
||||
],
|
||||
totalFiles: 1,
|
||||
totalLinesAdded: 162,
|
||||
scope: {
|
||||
...changeSet().scope,
|
||||
confidence: {
|
||||
tier: 2,
|
||||
label: 'medium',
|
||||
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
|
||||
},
|
||||
},
|
||||
warnings: ['Task start boundary missing - scoped by persisted workIntervals timestamps.'],
|
||||
});
|
||||
}
|
||||
|
||||
function warningFileResponse(): TeamTaskChangeSummariesResponse {
|
||||
return response({
|
||||
...changeSet(),
|
||||
files: [fileChange()],
|
||||
totalFiles: 1,
|
||||
totalLinesAdded: 1,
|
||||
warnings: ['Unexpected ledger warning.'],
|
||||
});
|
||||
}
|
||||
|
||||
function invalidFileSummaryResponse(): TeamTaskChangeSummariesResponse {
|
||||
return response({
|
||||
...changeSet(),
|
||||
|
|
@ -751,6 +785,114 @@ describe('useTeamChangesSummaries', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('hides work-interval scoping advisories in the compact Changes list when files are present', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(intervalScopedFileResponse());
|
||||
|
||||
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollIntoView'
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
try {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task({ status: 'completed', owner: 'jack' })],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand section"]'
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
expandButton?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('791/calculator.js');
|
||||
expect(container.textContent).not.toContain(
|
||||
'Task start boundary missing - scoped by persisted workIntervals timestamps.'
|
||||
);
|
||||
} finally {
|
||||
if (scrollIntoViewDescriptor) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
||||
} else {
|
||||
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps unrelated file warnings visible in the compact Changes list', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(warningFileResponse());
|
||||
|
||||
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollIntoView'
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
try {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task({ status: 'completed' })],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand section"]'
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
expandButton?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('src/app.ts');
|
||||
expect(container.textContent).toContain('Unexpected ledger warning.');
|
||||
} finally {
|
||||
if (scrollIntoViewDescriptor) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
||||
} else {
|
||||
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not clear completed task presence from an uncertain empty summary', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
||||
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ type ProvisioningDetailSummary =
|
|||
| 'Selected model unavailable'
|
||||
| 'Selected model verification timed out'
|
||||
| 'Selected model check failed'
|
||||
| 'Selected model verification deferred'
|
||||
| 'Selected model ping not confirmed'
|
||||
| 'Ready with notes'
|
||||
| 'Needs attention';
|
||||
|
|
@ -163,6 +164,7 @@ function isFormattedModelDetail(lower: string): boolean {
|
|||
lower.includes(' - compatible, deep verification pending') ||
|
||||
lower.includes(' - unavailable') ||
|
||||
lower.includes(' - check failed') ||
|
||||
lower.includes(' - verification deferred') ||
|
||||
lower.includes(' - ping not confirmed')
|
||||
);
|
||||
}
|
||||
|
|
@ -244,6 +246,9 @@ function summarizeDetail(
|
|||
if (isSelectedModelDetail(lower) && lower.includes('could not be verified')) {
|
||||
return 'Selected model check failed';
|
||||
}
|
||||
if (isSelectedModelDetail(lower) && lower.includes('verification deferred')) {
|
||||
return 'Selected model verification deferred';
|
||||
}
|
||||
if (lower.includes(' - verified')) {
|
||||
return 'Selected model verified';
|
||||
}
|
||||
|
|
@ -259,6 +264,9 @@ function summarizeDetail(
|
|||
if (lower.includes(' - check failed -')) {
|
||||
return 'Selected model check failed';
|
||||
}
|
||||
if (lower.includes(' - verification deferred')) {
|
||||
return 'Selected model verification deferred';
|
||||
}
|
||||
if (lower.includes(' - ping not confirmed')) {
|
||||
return 'Selected model ping not confirmed';
|
||||
}
|
||||
|
|
@ -279,6 +287,7 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
let unavailableCount = 0;
|
||||
let timedOutCount = 0;
|
||||
let checkFailedCount = 0;
|
||||
let deferredCount = 0;
|
||||
let pingNotConfirmedCount = 0;
|
||||
let checkingCount = 0;
|
||||
|
||||
|
|
@ -327,6 +336,13 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
checkFailedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower.includes(' - verification deferred') ||
|
||||
(isSelectedModelDetail(lower) && lower.includes('verification deferred'))
|
||||
) {
|
||||
deferredCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (lower.includes(' - ping not confirmed')) {
|
||||
pingNotConfirmedCount += 1;
|
||||
continue;
|
||||
|
|
@ -346,6 +362,9 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
if (timedOutCount > 0) {
|
||||
parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
|
||||
}
|
||||
if (deferredCount > 0) {
|
||||
parts.push(`${deferredCount} verification deferred`);
|
||||
}
|
||||
if (pingNotConfirmedCount > 0) {
|
||||
parts.push(`${pingNotConfirmedCount} ping not confirmed`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -513,6 +513,48 @@ const OpenCodeVirtualizedModelGrid = ({
|
|||
);
|
||||
};
|
||||
|
||||
const OpenCodeModelCatalogLoadingSkeleton = (): React.JSX.Element => (
|
||||
<div
|
||||
data-testid="team-model-selector-opencode-loading-skeleton"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-blue-400" />
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
Loading OpenCode models...
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="min-h-[44px] rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-2"
|
||||
>
|
||||
<div
|
||||
className="skeleton-shimmer mx-auto mb-1.5 h-3 rounded-sm"
|
||||
style={{
|
||||
width: index === 1 ? '64%' : '76%',
|
||||
backgroundColor: 'var(--skeleton-base)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="skeleton-shimmer mx-auto h-2 rounded-sm"
|
||||
style={{
|
||||
width: index === 2 ? '44%' : '52%',
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface TeamModelSelectorProps {
|
||||
providerId: TeamProviderId;
|
||||
onProviderChange: (providerId: TeamProviderId) => void;
|
||||
|
|
@ -957,11 +999,18 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
const visibleConcreteModelOptionCount =
|
||||
visibleModelOptions.length - visibleDefaultModelOptions.length;
|
||||
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length;
|
||||
const shouldShowModelSearch = concreteModelOptionCount > 8;
|
||||
const shouldShowOpenCodeCatalogLoading =
|
||||
effectiveProviderId === 'opencode' &&
|
||||
runtimeProviderStatus?.modelCatalogRefreshState === 'loading' &&
|
||||
runtimeProviderStatus.modelCatalog?.providerId !== 'opencode' &&
|
||||
(runtimeProviderStatus.models.length === 0 ||
|
||||
runtimeProviderStatus.models.every((model) => model.trim() === 'opencode/big-pickle'));
|
||||
const shouldShowModelSearch = !shouldShowOpenCodeCatalogLoading && concreteModelOptionCount > 8;
|
||||
const trimmedModelQuery = modelQuery.trim();
|
||||
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
|
||||
const shouldVirtualizeOpenCodeModels =
|
||||
effectiveProviderId === 'opencode' &&
|
||||
!shouldShowOpenCodeCatalogLoading &&
|
||||
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
|
||||
const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
|
||||
reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
|
||||
|
|
@ -1270,8 +1319,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{(effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
|
||||
hasRecommendedOpenCodeModels ? (
|
||||
{!shouldShowOpenCodeCatalogLoading &&
|
||||
((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
|
||||
hasRecommendedOpenCodeModels) ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
|
||||
<Popover
|
||||
|
|
@ -1370,7 +1420,22 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
</div>
|
||||
) : null}
|
||||
{effectiveProviderId === 'opencode' ? (
|
||||
shouldVirtualizeOpenCodeModels ? (
|
||||
shouldShowOpenCodeCatalogLoading ? (
|
||||
<div
|
||||
data-testid="team-model-selector-model-grid"
|
||||
className="space-y-3 rounded-md bg-[var(--color-surface)]"
|
||||
>
|
||||
{visibleDefaultModelOptions.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
>
|
||||
{visibleDefaultModelOptions.map(renderModelOption)}
|
||||
</div>
|
||||
) : null}
|
||||
<OpenCodeModelCatalogLoadingSkeleton />
|
||||
</div>
|
||||
) : shouldVirtualizeOpenCodeModels ? (
|
||||
<OpenCodeVirtualizedModelGrid
|
||||
defaultOptions={visibleDefaultModelOptions}
|
||||
groups={visibleOpenCodeModelGroups}
|
||||
|
|
@ -1437,7 +1502,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
{visibleModelOptions.map(renderModelOption)}
|
||||
</div>
|
||||
)}
|
||||
{visibleModelOptions.length === 0 ? (
|
||||
{visibleModelOptions.length === 0 && !shouldShowOpenCodeCatalogLoading ? (
|
||||
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
{trimmedModelQuery
|
||||
? 'No models match this search.'
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ function stripSelectedModelPrefix(modelId: string, message: string): string {
|
|||
const patterns = [
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} is unavailable\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} could not be verified\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} verification deferred\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} verified for launch\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} is available for launch\\.\\s*`, 'i'),
|
||||
new RegExp(
|
||||
|
|
@ -420,6 +421,17 @@ function buildModelFailureLine(
|
|||
return reason ? `${label} - ${kind} - ${reason}` : `${label} - ${kind}`;
|
||||
}
|
||||
|
||||
function buildModelVerificationDeferredLine(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string,
|
||||
reason: string | null
|
||||
): string {
|
||||
const label = getModelLabel(providerId, modelId);
|
||||
return reason
|
||||
? `${label} - verification deferred - ${reason}`
|
||||
: `${label} - verification deferred`;
|
||||
}
|
||||
|
||||
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
|
||||
return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]);
|
||||
}
|
||||
|
|
@ -574,6 +586,18 @@ function resolveModelResultFromBatch(
|
|||
};
|
||||
}
|
||||
|
||||
const hasVerificationDeferredLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* verification deferred\./i.test(entry)
|
||||
);
|
||||
if (hasVerificationDeferredLine) {
|
||||
const line = buildModelVerificationDeferredLine(providerId, modelId, scopedReason);
|
||||
return {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
};
|
||||
}
|
||||
|
||||
const hasUnavailableLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is unavailable\./i.test(entry)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -105,7 +105,11 @@ export const CurrentTaskIndicator = memo(
|
|||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
|
||||
<SyncedLoader2
|
||||
className="size-3 shrink-0"
|
||||
spinning={isTimerRunning}
|
||||
style={{ color: borderColor }}
|
||||
/>
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||
|
|
@ -26,7 +26,20 @@ import {
|
|||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, Ban, GitBranch, MessageSquare, Plus, RotateCcw } from 'lucide-react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
Cpu,
|
||||
GitBranch,
|
||||
HardDrive,
|
||||
Info,
|
||||
Layers3,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
|
|
@ -46,6 +59,11 @@ import type {
|
|||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface RuntimeTelemetryScale {
|
||||
memoryCapBytes?: number;
|
||||
cpuCapPercent?: number;
|
||||
}
|
||||
|
||||
interface MemberCardProps {
|
||||
member: ResolvedTeamMember;
|
||||
memberColor: string;
|
||||
|
|
@ -72,6 +90,8 @@ interface MemberCardProps {
|
|||
spawnLaunchState?: MemberLaunchState;
|
||||
spawnRuntimeAlive?: boolean;
|
||||
isLaunchSettling?: boolean;
|
||||
runtimeTelemetryVisible?: boolean;
|
||||
runtimeTelemetryScale?: RuntimeTelemetryScale;
|
||||
onOpenTask?: () => void;
|
||||
onOpenReviewTask?: () => void;
|
||||
onClick?: () => void;
|
||||
|
|
@ -170,6 +190,232 @@ function buildAreaPath(points: readonly TelemetryPoint[]): string | undefined {
|
|||
].join(' ');
|
||||
}
|
||||
|
||||
function getRelativeTelemetryY(
|
||||
value: number,
|
||||
values: readonly number[],
|
||||
options: {
|
||||
bottomY: number;
|
||||
amplitude: number;
|
||||
fallbackRatio: number;
|
||||
minimumSpan?: number;
|
||||
}
|
||||
): number {
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const span = max - min;
|
||||
if (span <= 0) {
|
||||
return options.bottomY - options.fallbackRatio * options.amplitude;
|
||||
}
|
||||
|
||||
const effectiveSpan = Math.max(span, options.minimumSpan ?? 0);
|
||||
const ratio = Math.max(0, Math.min(1, (value - min) / effectiveSpan));
|
||||
return options.bottomY - ratio * options.amplitude;
|
||||
}
|
||||
|
||||
function getCappedTelemetryY(
|
||||
value: number,
|
||||
cap: number | undefined,
|
||||
options: {
|
||||
bottomY: number;
|
||||
amplitude: number;
|
||||
curve?: 'linear' | 'sqrt';
|
||||
}
|
||||
): number | undefined {
|
||||
if (!isFiniteNonNegative(cap) || cap <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const rawRatio = Math.max(0, Math.min(1, value / cap));
|
||||
const ratio = options.curve === 'sqrt' ? Math.sqrt(rawRatio) : rawRatio;
|
||||
return options.bottomY - ratio * options.amplitude;
|
||||
}
|
||||
|
||||
function formatRuntimeTelemetryPercent(value: number | undefined): string | undefined {
|
||||
if (!isFiniteNonNegative(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return `${value >= 10 ? Math.round(value) : value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatRuntimeTelemetryBytes(value: number | undefined): string | undefined {
|
||||
if (!isFiniteNonNegative(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const mib = value / (1024 * 1024);
|
||||
if (mib < 1024) {
|
||||
return `${mib.toFixed(mib >= 10 ? 0 : 1)} MB`;
|
||||
}
|
||||
return `${(mib / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function isRuntimeTelemetrySampleLike(value: unknown): value is TeamAgentRuntimeResourceSample {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const sample = value as Partial<TeamAgentRuntimeResourceSample>;
|
||||
return (
|
||||
typeof sample.timestamp === 'string' ||
|
||||
isFiniteNonNegative(sample.cpuPercent) ||
|
||||
isFiniteNonNegative(sample.rssBytes)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRuntimeTelemetrySamples(history: unknown): TeamAgentRuntimeResourceSample[] {
|
||||
return (Array.isArray(history) ? history : []).filter(isRuntimeTelemetrySampleLike);
|
||||
}
|
||||
|
||||
function buildRuntimeTelemetryTitle(
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||
): string | undefined {
|
||||
if (!runtimeEntry) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalizeRuntimeTelemetrySamples(runtimeEntry?.resourceHistory).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'CPU includes parent + child processes.',
|
||||
'Local CPU excludes remote LLM inference.',
|
||||
];
|
||||
if (runtimeEntry.runtimeLoadScope === 'shared-host') {
|
||||
lines.push('Shared OpenCode host metric; not exclusive to this member.');
|
||||
}
|
||||
if (runtimeEntry.runtimeLoadTruncated) {
|
||||
lines.push('Process tree was capped for this sample.');
|
||||
}
|
||||
|
||||
const detailParts = [
|
||||
runtimeEntry.pid ? `root PID ${runtimeEntry.pid}` : undefined,
|
||||
runtimeEntry.processCount ? `${runtimeEntry.processCount} processes` : undefined,
|
||||
runtimeEntry.runtimeLoadScope ? `scope ${runtimeEntry.runtimeLoadScope}` : undefined,
|
||||
'sample 5s',
|
||||
].filter((part): part is string => Boolean(part));
|
||||
if (detailParts.length > 0) {
|
||||
lines.push(detailParts.join(' · '));
|
||||
}
|
||||
|
||||
const aggregateCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.cpuPercent);
|
||||
const primaryCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.primaryCpuPercent);
|
||||
const childCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.childCpuPercent);
|
||||
const rssLabel = formatRuntimeTelemetryBytes(runtimeEntry.rssBytes);
|
||||
const splitParts = [
|
||||
aggregateCpuLabel ? `CPU ${aggregateCpuLabel}` : undefined,
|
||||
primaryCpuLabel ? `root ${primaryCpuLabel}` : undefined,
|
||||
childCpuLabel ? `children ${childCpuLabel}` : undefined,
|
||||
rssLabel ? `RSS ${rssLabel}` : undefined,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
if (splitParts.length > 0) {
|
||||
lines.push(splitParts.join(' · '));
|
||||
}
|
||||
|
||||
lines.push('RSS is summed process RSS and can include shared pages.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function RuntimeTelemetryTooltipContent({
|
||||
runtimeEntry,
|
||||
}: {
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined;
|
||||
}): React.JSX.Element | null {
|
||||
if (!runtimeEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aggregateCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.cpuPercent);
|
||||
const primaryCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.primaryCpuPercent);
|
||||
const childCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.childCpuPercent);
|
||||
const rssLabel = formatRuntimeTelemetryBytes(runtimeEntry.rssBytes);
|
||||
const detailParts = [
|
||||
runtimeEntry.pid ? `root PID ${runtimeEntry.pid}` : undefined,
|
||||
runtimeEntry.processCount ? `${runtimeEntry.processCount} processes` : undefined,
|
||||
runtimeEntry.runtimeLoadScope ? `scope ${runtimeEntry.runtimeLoadScope}` : undefined,
|
||||
'sample 5s',
|
||||
].filter((part): part is string => Boolean(part));
|
||||
const cpuSplit = [
|
||||
primaryCpuLabel ? `root ${primaryCpuLabel}` : undefined,
|
||||
childCpuLabel ? `children ${childCpuLabel}` : undefined,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return (
|
||||
<div className="w-[320px] max-w-[min(320px,var(--radix-tooltip-content-available-width))] space-y-2.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md border border-blue-500/30 bg-blue-500/10 text-blue-300">
|
||||
<Activity className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-semibold leading-tight text-[var(--color-text)]">
|
||||
Local runtime load
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] leading-snug text-[var(--color-text-muted)]">
|
||||
Parent and child processes only. Remote LLM inference is not included.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-blue-200/80">
|
||||
<Cpu className="size-3" />
|
||||
CPU
|
||||
</div>
|
||||
<div className="mt-1 text-[14px] font-semibold text-blue-100">
|
||||
{aggregateCpuLabel ?? 'unknown'}
|
||||
</div>
|
||||
{cpuSplit.length > 0 ? (
|
||||
<div className="mt-0.5 text-[10px] leading-snug text-blue-100/65">
|
||||
{cpuSplit.join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-emerald-200/80">
|
||||
<HardDrive className="size-3" />
|
||||
Memory
|
||||
</div>
|
||||
<div className="mt-1 text-[14px] font-semibold text-emerald-100">
|
||||
{rssLabel ?? 'unknown'}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] leading-snug text-emerald-100/65">summed RSS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailParts.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{detailParts.map((part) => (
|
||||
<span
|
||||
key={part}
|
||||
className="inline-flex items-center gap-1 rounded border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-1.5 py-0.5 text-[10px] leading-none text-[var(--color-text-muted)]"
|
||||
>
|
||||
<Layers3 className="size-2.5" />
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runtimeEntry.runtimeLoadScope === 'shared-host' ? (
|
||||
<div className="flex gap-1.5 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/80">
|
||||
<Server className="mt-0.5 size-3 shrink-0" />
|
||||
Shared OpenCode host metric. It is not exclusive to this member.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runtimeEntry.runtimeLoadTruncated ? (
|
||||
<div className="flex gap-1.5 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/80">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
Process tree was capped for this sample.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-1.5 border-t border-[var(--color-border)] pt-2 text-[10px] leading-snug text-[var(--color-text-muted)]">
|
||||
<Info className="mt-0.5 size-3 shrink-0" />
|
||||
RSS can include shared pages, so it is best read as a load signal, not exclusive memory.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTelemetryPoints(
|
||||
samples: readonly TeamAgentRuntimeResourceSample[],
|
||||
getValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined,
|
||||
|
|
@ -194,9 +440,10 @@ function buildTelemetryPoints(
|
|||
}
|
||||
|
||||
function buildRuntimeTelemetryPaths(
|
||||
history: readonly TeamAgentRuntimeResourceSample[] | undefined
|
||||
history: readonly TeamAgentRuntimeResourceSample[] | undefined,
|
||||
scale?: RuntimeTelemetryScale
|
||||
): RuntimeTelemetryPaths | undefined {
|
||||
const samples = (history ?? []).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT);
|
||||
const samples = normalizeRuntimeTelemetrySamples(history).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT);
|
||||
if (samples.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -205,19 +452,38 @@ function buildRuntimeTelemetryPaths(
|
|||
samples,
|
||||
(sample) => sample.rssBytes,
|
||||
(value, values) => {
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const ratio = max > min ? (value - min) / (max - min) : 0.32;
|
||||
return 15.25 - ratio * 4.4;
|
||||
const cappedY = getCappedTelemetryY(value, scale?.memoryCapBytes, {
|
||||
bottomY: 15.25,
|
||||
amplitude: 4.4,
|
||||
});
|
||||
return (
|
||||
cappedY ??
|
||||
getRelativeTelemetryY(value, values, {
|
||||
bottomY: 15.25,
|
||||
amplitude: 4.4,
|
||||
fallbackRatio: 0.32,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
const cpuPoints = buildTelemetryPoints(
|
||||
samples,
|
||||
(sample) => sample.cpuPercent,
|
||||
(value, values) => {
|
||||
const max = Math.max(10, ...values);
|
||||
const ratio = Math.min(1, value / max);
|
||||
return 8.3 - ratio * 4.6;
|
||||
const cappedY = getCappedTelemetryY(value, scale?.cpuCapPercent, {
|
||||
bottomY: 16.1,
|
||||
amplitude: 5.2,
|
||||
curve: 'sqrt',
|
||||
});
|
||||
return (
|
||||
cappedY ??
|
||||
getRelativeTelemetryY(value, values, {
|
||||
bottomY: 16.1,
|
||||
amplitude: 5.2,
|
||||
fallbackRatio: 0,
|
||||
minimumSpan: 0.5,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -236,12 +502,16 @@ function buildRuntimeTelemetryPaths(
|
|||
|
||||
const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
||||
runtimeEntry,
|
||||
visible,
|
||||
scale,
|
||||
}: {
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
visible: boolean;
|
||||
scale?: RuntimeTelemetryScale;
|
||||
}): React.JSX.Element | null {
|
||||
const paths = useMemo(
|
||||
() => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory),
|
||||
[runtimeEntry?.resourceHistory]
|
||||
() => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory, scale),
|
||||
[runtimeEntry?.resourceHistory, scale]
|
||||
);
|
||||
if (!paths) {
|
||||
return null;
|
||||
|
|
@ -251,7 +521,16 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
|||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="member-runtime-telemetry-strip"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-5 overflow-hidden rounded-b"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 z-0 h-5 overflow-hidden rounded-b transition-opacity duration-150',
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to right, transparent 0, black 44px, black calc(100% - 44px), transparent 100%)',
|
||||
maskImage:
|
||||
'linear-gradient(to right, transparent 0, black 44px, black calc(100% - 44px), transparent 100%)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="size-full"
|
||||
|
|
@ -259,7 +538,7 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
|||
preserveAspectRatio="none"
|
||||
>
|
||||
{paths.memoryAreaPath ? (
|
||||
<path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.22" />
|
||||
<path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.14" />
|
||||
) : null}
|
||||
{paths.memoryLinePath ? (
|
||||
<path
|
||||
|
|
@ -269,7 +548,7 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
|||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="0.55"
|
||||
opacity="0.68"
|
||||
opacity="0.45"
|
||||
/>
|
||||
) : null}
|
||||
{paths.cpuLinePath ? (
|
||||
|
|
@ -280,17 +559,19 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
|||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="0.62"
|
||||
opacity="0.78"
|
||||
opacity="0.62"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-2"
|
||||
className="absolute inset-x-0 bottom-0 h-1.5"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, color-mix(in srgb, var(--color-surface) 35%, transparent), transparent)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-[var(--color-surface)] to-transparent opacity-80 blur-[1px]" />
|
||||
<div className="absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-[var(--color-surface)] to-transparent opacity-80 blur-[1px]" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -321,6 +602,8 @@ export const MemberCard = memo(function MemberCard({
|
|||
spawnLaunchState,
|
||||
spawnRuntimeAlive,
|
||||
isLaunchSettling,
|
||||
runtimeTelemetryVisible = false,
|
||||
runtimeTelemetryScale,
|
||||
onOpenTask,
|
||||
onOpenReviewTask,
|
||||
onClick,
|
||||
|
|
@ -412,6 +695,47 @@ export const MemberCard = memo(function MemberCard({
|
|||
: reviewTask
|
||||
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
|
||||
: undefined;
|
||||
const runtimeTelemetryTitle = buildRuntimeTelemetryTitle(runtimeEntry);
|
||||
const showRuntimeTelemetryTooltip = runtimeTelemetryVisible && Boolean(runtimeTelemetryTitle);
|
||||
const rowTitle = showRuntimeTelemetryTooltip ? undefined : activityTitle;
|
||||
const runtimeTelemetryTooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [runtimeTelemetryTooltipOpen, setRuntimeTelemetryTooltipOpen] = useState(false);
|
||||
const clearRuntimeTelemetryTooltipTimer = useCallback(() => {
|
||||
if (runtimeTelemetryTooltipTimerRef.current == null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(runtimeTelemetryTooltipTimerRef.current);
|
||||
runtimeTelemetryTooltipTimerRef.current = null;
|
||||
}, []);
|
||||
const handleRuntimeTelemetryTooltipOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
clearRuntimeTelemetryTooltipTimer();
|
||||
if (!nextOpen) {
|
||||
setRuntimeTelemetryTooltipOpen(false);
|
||||
return;
|
||||
}
|
||||
if (runtimeTelemetryTooltipOpen) {
|
||||
return;
|
||||
}
|
||||
runtimeTelemetryTooltipTimerRef.current = setTimeout(() => {
|
||||
runtimeTelemetryTooltipTimerRef.current = null;
|
||||
setRuntimeTelemetryTooltipOpen(true);
|
||||
}, 1000);
|
||||
},
|
||||
[clearRuntimeTelemetryTooltipTimer, runtimeTelemetryTooltipOpen]
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearRuntimeTelemetryTooltipTimer();
|
||||
},
|
||||
[clearRuntimeTelemetryTooltipTimer]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!showRuntimeTelemetryTooltip) {
|
||||
clearRuntimeTelemetryTooltipTimer();
|
||||
setRuntimeTelemetryTooltipOpen(false);
|
||||
}
|
||||
}, [clearRuntimeTelemetryTooltipTimer, showRuntimeTelemetryTooltip]);
|
||||
const showStartingSkeleton =
|
||||
!isRemoved &&
|
||||
presenceLabel === 'starting' &&
|
||||
|
|
@ -439,15 +763,21 @@ export const MemberCard = memo(function MemberCard({
|
|||
teamName: selectedTeamName,
|
||||
runId: runtimeRunId,
|
||||
memberName: member.name,
|
||||
member,
|
||||
spawnStatus,
|
||||
launchState: spawnLaunchState,
|
||||
livenessSource: spawnLivenessSource,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
runtimeAdvisoryLabel,
|
||||
runtimeAdvisoryTitle,
|
||||
}),
|
||||
[
|
||||
member.name,
|
||||
member,
|
||||
runtimeEntry,
|
||||
runtimeAdvisoryLabel,
|
||||
runtimeAdvisoryTitle,
|
||||
runtimeRunId,
|
||||
selectedTeamName,
|
||||
spawnEntry,
|
||||
|
|
@ -460,6 +790,11 @@ export const MemberCard = memo(function MemberCard({
|
|||
!isRemoved &&
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const showRuntimeAdvisoryDiagnostics =
|
||||
!isRemoved &&
|
||||
Boolean(runtimeAdvisoryLabel) &&
|
||||
runtimeAdvisoryTone === 'error' &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
|
||||
const isSkippedLaunch =
|
||||
spawnStatus === 'skipped' ||
|
||||
|
|
@ -503,13 +838,26 @@ export const MemberCard = memo(function MemberCard({
|
|||
!isFailedLaunch &&
|
||||
!isSkippedLaunch &&
|
||||
(Boolean(activityTask) || !isAwaitingReply);
|
||||
const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate';
|
||||
const restartActionBusyLabel = canRelaunchOpenCode
|
||||
? 'Relaunching OpenCode teammate'
|
||||
: 'Retrying teammate';
|
||||
const restartActionErrorFallback = canRelaunchOpenCode
|
||||
? 'Failed to relaunch OpenCode teammate'
|
||||
: 'Failed to retry teammate';
|
||||
const canRelaunchRuntimeAdvisoryOpenCode =
|
||||
Boolean(runtimeAdvisoryLabel) &&
|
||||
runtimeAdvisoryTone === 'error' &&
|
||||
member.providerId === 'opencode' &&
|
||||
hasRestartMemberControl &&
|
||||
!showLaunchBadge &&
|
||||
!isFailedLaunch &&
|
||||
!isSkippedLaunch;
|
||||
const restartActionIdleLabel =
|
||||
canRelaunchOpenCode || canRelaunchRuntimeAdvisoryOpenCode
|
||||
? 'Relaunch OpenCode'
|
||||
: 'Retry teammate';
|
||||
const restartActionBusyLabel =
|
||||
canRelaunchOpenCode || canRelaunchRuntimeAdvisoryOpenCode
|
||||
? 'Relaunching OpenCode teammate'
|
||||
: 'Retrying teammate';
|
||||
const restartActionErrorFallback =
|
||||
canRelaunchOpenCode || canRelaunchRuntimeAdvisoryOpenCode
|
||||
? 'Failed to relaunch OpenCode teammate'
|
||||
: 'Failed to retry teammate';
|
||||
const handleRestartMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -545,7 +893,7 @@ export const MemberCard = memo(function MemberCard({
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const cardContent = (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded transition-opacity duration-300',
|
||||
|
|
@ -560,7 +908,7 @@ export const MemberCard = memo(function MemberCard({
|
|||
rowSurfaceBleedClass
|
||||
)}
|
||||
style={undefined}
|
||||
title={activityTitle}
|
||||
title={rowTitle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
|
|
@ -571,7 +919,13 @@ export const MemberCard = memo(function MemberCard({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{!isRemoved ? <MemberRuntimeTelemetryStrip runtimeEntry={runtimeEntry} /> : null}
|
||||
{!isRemoved ? (
|
||||
<MemberRuntimeTelemetryStrip
|
||||
runtimeEntry={runtimeEntry}
|
||||
visible={runtimeTelemetryVisible}
|
||||
scale={runtimeTelemetryScale}
|
||||
/>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-0 z-10 rounded transition-colors group-hover:bg-white/5" />
|
||||
<div className="relative z-20 flex items-center gap-2.5">
|
||||
<div className="relative shrink-0">
|
||||
|
|
@ -662,6 +1016,39 @@ export const MemberCard = memo(function MemberCard({
|
|||
>
|
||||
{runtimeAdvisoryLabel ?? 'awaiting reply'}
|
||||
</span>
|
||||
{canRelaunchRuntimeAdvisoryOpenCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
|
||||
}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch
|
||||
? `${restartActionBusyLabel}...`
|
||||
: restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showRuntimeAdvisoryDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -869,31 +1256,62 @@ export const MemberCard = memo(function MemberCard({
|
|||
) : null}
|
||||
</span>
|
||||
) : showRuntimeAdvisoryBadge ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle
|
||||
className={`size-3.5 shrink-0 ${
|
||||
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'bg-red-500/15 text-red-300'
|
||||
: 'bg-amber-500/15 text-amber-300'
|
||||
}`}
|
||||
title={runtimeAdvisoryTitle}
|
||||
>
|
||||
{runtimeAdvisoryLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle
|
||||
className={`size-3.5 shrink-0 ${
|
||||
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'bg-red-500/15 text-red-300'
|
||||
: 'bg-amber-500/15 text-amber-300'
|
||||
}`}
|
||||
title={runtimeAdvisoryTitle}
|
||||
>
|
||||
{runtimeAdvisoryLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canRelaunchRuntimeAdvisoryOpenCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showRuntimeAdvisoryDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
) : !activityTask ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
|
@ -977,4 +1395,26 @@ export const MemberCard = memo(function MemberCard({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showRuntimeTelemetryTooltip) {
|
||||
return cardContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
delayDuration={0}
|
||||
open={runtimeTelemetryTooltipOpen}
|
||||
onOpenChange={handleRuntimeTelemetryTooltipOpenChange}
|
||||
>
|
||||
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="border-blue-400/20 bg-[var(--color-surface)] p-3 shadow-xl shadow-black/30"
|
||||
>
|
||||
<RuntimeTelemetryTooltipContent runtimeEntry={runtimeEntry} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||
|
||||
import { MemberCard } from './MemberCard';
|
||||
import { MemberCard, type RuntimeTelemetryScale } from './MemberCard';
|
||||
|
||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
||||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamAgentRuntimeResourceSample,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -44,6 +45,8 @@ interface MemberListProps {
|
|||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
launchParams?: TeamLaunchParams;
|
||||
runtimeTelemetryVisible?: boolean;
|
||||
onRuntimeTelemetryHoverChange?: (visible: boolean) => void;
|
||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||
|
|
@ -264,6 +267,32 @@ function areLaunchParamsEquivalent(
|
|||
);
|
||||
}
|
||||
|
||||
function isRuntimeResourceSampleLike(value: unknown): value is TeamAgentRuntimeResourceSample {
|
||||
return Boolean(value) && typeof value === 'object';
|
||||
}
|
||||
|
||||
function areRuntimeResourceSamplesEquivalent(left: unknown, right: unknown): boolean {
|
||||
if (left === right) return true;
|
||||
if (!isRuntimeResourceSampleLike(left) || !isRuntimeResourceSampleLike(right)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
left.timestamp === right.timestamp &&
|
||||
left.cpuPercent === right.cpuPercent &&
|
||||
left.rssBytes === right.rssBytes &&
|
||||
left.primaryCpuPercent === right.primaryCpuPercent &&
|
||||
left.primaryRssBytes === right.primaryRssBytes &&
|
||||
left.childCpuPercent === right.childCpuPercent &&
|
||||
left.childRssBytes === right.childRssBytes &&
|
||||
left.processCount === right.processCount &&
|
||||
left.runtimeLoadScope === right.runtimeLoadScope &&
|
||||
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
|
||||
left.pidSource === right.pidSource &&
|
||||
left.pid === right.pid &&
|
||||
left.runtimePid === right.runtimePid
|
||||
);
|
||||
}
|
||||
|
||||
function areMemberRuntimeEntriesEquivalent(
|
||||
left: Map<string, TeamAgentRuntimeEntry> | undefined,
|
||||
right: Map<string, TeamAgentRuntimeEntry> | undefined
|
||||
|
|
@ -273,10 +302,15 @@ function areMemberRuntimeEntriesEquivalent(
|
|||
if (left.size !== right.size) return false;
|
||||
for (const [key, leftEntry] of left) {
|
||||
const rightEntry = right.get(key);
|
||||
const leftDiagnostics = leftEntry.diagnostics ?? [];
|
||||
const rightDiagnostics = rightEntry?.diagnostics ?? [];
|
||||
const leftResourceHistory = leftEntry.resourceHistory ?? [];
|
||||
const rightResourceHistory = rightEntry?.resourceHistory ?? [];
|
||||
const leftDiagnostics = Array.isArray(leftEntry.diagnostics) ? leftEntry.diagnostics : [];
|
||||
const rightDiagnostics = Array.isArray(rightEntry?.diagnostics) ? rightEntry.diagnostics : [];
|
||||
const rightResourceHistoryCandidate = rightEntry?.resourceHistory;
|
||||
const leftResourceHistory = Array.isArray(leftEntry.resourceHistory)
|
||||
? leftEntry.resourceHistory
|
||||
: [];
|
||||
const rightResourceHistory = Array.isArray(rightResourceHistoryCandidate)
|
||||
? rightResourceHistoryCandidate
|
||||
: [];
|
||||
if (
|
||||
leftEntry.memberName !== rightEntry?.memberName ||
|
||||
leftEntry.alive !== rightEntry?.alive ||
|
||||
|
|
@ -290,6 +324,13 @@ function areMemberRuntimeEntriesEquivalent(
|
|||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
||||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
||||
leftEntry.cpuPercent !== rightEntry?.cpuPercent ||
|
||||
leftEntry.primaryCpuPercent !== rightEntry?.primaryCpuPercent ||
|
||||
leftEntry.primaryRssBytes !== rightEntry?.primaryRssBytes ||
|
||||
leftEntry.childCpuPercent !== rightEntry?.childCpuPercent ||
|
||||
leftEntry.childRssBytes !== rightEntry?.childRssBytes ||
|
||||
leftEntry.processCount !== rightEntry?.processCount ||
|
||||
leftEntry.runtimeLoadScope !== rightEntry?.runtimeLoadScope ||
|
||||
leftEntry.runtimeLoadTruncated !== rightEntry?.runtimeLoadTruncated ||
|
||||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
|
||||
leftEntry.pidSource !== rightEntry?.pidSource ||
|
||||
leftEntry.processCommand !== rightEntry?.processCommand ||
|
||||
|
|
@ -305,17 +346,9 @@ function areMemberRuntimeEntriesEquivalent(
|
|||
leftDiagnostics.length !== rightDiagnostics.length ||
|
||||
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ||
|
||||
leftResourceHistory.length !== rightResourceHistory.length ||
|
||||
!leftResourceHistory.every((value, index) => {
|
||||
const other = rightResourceHistory[index];
|
||||
return (
|
||||
value.timestamp === other?.timestamp &&
|
||||
value.cpuPercent === other?.cpuPercent &&
|
||||
value.rssBytes === other?.rssBytes &&
|
||||
value.pidSource === other?.pidSource &&
|
||||
value.pid === other?.pid &&
|
||||
value.runtimePid === other?.runtimePid
|
||||
);
|
||||
})
|
||||
!leftResourceHistory.every((value, index) =>
|
||||
areRuntimeResourceSamplesEquivalent(value, rightResourceHistory[index])
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -323,6 +356,95 @@ function areMemberRuntimeEntriesEquivalent(
|
|||
return true;
|
||||
}
|
||||
|
||||
function isFiniteNonNegative(value: number | undefined): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[], percentileValue: number): number | undefined {
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const rank = (sorted.length - 1) * percentileValue;
|
||||
const lowerIndex = Math.floor(rank);
|
||||
const upperIndex = Math.ceil(rank);
|
||||
const lower = sorted[lowerIndex];
|
||||
const upper = sorted[upperIndex];
|
||||
if (lower == null || upper == null) {
|
||||
return sorted[sorted.length - 1];
|
||||
}
|
||||
if (lowerIndex === upperIndex) {
|
||||
return lower;
|
||||
}
|
||||
return lower + (upper - lower) * (rank - lowerIndex);
|
||||
}
|
||||
|
||||
function collectRuntimeTelemetryValues(
|
||||
entry: TeamAgentRuntimeEntry | undefined,
|
||||
getSampleValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined,
|
||||
currentValue: number | undefined
|
||||
): { historyValues: number[]; currentValues: number[] } {
|
||||
const history = Array.isArray(entry?.resourceHistory) ? entry.resourceHistory : [];
|
||||
const historyValues = history.flatMap((sample) => {
|
||||
if (!isRuntimeResourceSampleLike(sample)) {
|
||||
return [];
|
||||
}
|
||||
const value = getSampleValue(sample);
|
||||
return isFiniteNonNegative(value) ? [value] : [];
|
||||
});
|
||||
const currentValues = isFiniteNonNegative(currentValue) ? [currentValue] : [];
|
||||
return { historyValues, currentValues };
|
||||
}
|
||||
|
||||
function buildRuntimeTelemetryScale(
|
||||
members: readonly ResolvedTeamMember[],
|
||||
runtimeEntries: Map<string, TeamAgentRuntimeEntry> | undefined
|
||||
): RuntimeTelemetryScale | undefined {
|
||||
if (!runtimeEntries || members.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const memoryHistoryValues: number[] = [];
|
||||
const memoryCurrentValues: number[] = [];
|
||||
const cpuHistoryValues: number[] = [];
|
||||
const cpuCurrentValues: number[] = [];
|
||||
|
||||
for (const member of members) {
|
||||
const runtimeEntry = runtimeEntries.get(member.name);
|
||||
const memoryValues = collectRuntimeTelemetryValues(
|
||||
runtimeEntry,
|
||||
(sample) => sample.rssBytes,
|
||||
runtimeEntry?.rssBytes
|
||||
);
|
||||
memoryHistoryValues.push(...memoryValues.historyValues);
|
||||
memoryCurrentValues.push(...memoryValues.currentValues);
|
||||
|
||||
const cpuValues = collectRuntimeTelemetryValues(
|
||||
runtimeEntry,
|
||||
(sample) => sample.cpuPercent,
|
||||
runtimeEntry?.cpuPercent
|
||||
);
|
||||
cpuHistoryValues.push(...cpuValues.historyValues);
|
||||
cpuCurrentValues.push(...cpuValues.currentValues);
|
||||
}
|
||||
|
||||
const memoryP95 = percentile(memoryHistoryValues, 0.95);
|
||||
const memoryCurrentMax =
|
||||
memoryCurrentValues.length > 0 ? Math.max(...memoryCurrentValues) : undefined;
|
||||
const memoryReference = Math.max(memoryP95 ?? 0, memoryCurrentMax ?? 0);
|
||||
|
||||
const cpuP95 = percentile(cpuHistoryValues, 0.95);
|
||||
const cpuCurrentMax = cpuCurrentValues.length > 0 ? Math.max(...cpuCurrentValues) : undefined;
|
||||
const cpuReference = Math.max(cpuP95 ?? 0, cpuCurrentMax ?? 0);
|
||||
const hasCpuValues = cpuHistoryValues.length > 0 || cpuCurrentValues.length > 0;
|
||||
|
||||
const scale: RuntimeTelemetryScale = {
|
||||
...(memoryReference > 0 ? { memoryCapBytes: memoryReference * 1.1 } : {}),
|
||||
...(hasCpuValues ? { cpuCapPercent: Math.max(25, cpuReference) } : {}),
|
||||
};
|
||||
return scale.memoryCapBytes != null || scale.cpuCapPercent != null ? scale : undefined;
|
||||
}
|
||||
|
||||
function areMemberListPropsEqual(
|
||||
prev: Readonly<MemberListProps>,
|
||||
next: Readonly<MemberListProps>
|
||||
|
|
@ -342,6 +464,8 @@ function areMemberListPropsEqual(
|
|||
prev.isTeamAlive === next.isTeamAlive &&
|
||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||
prev.leadActivity === next.leadActivity &&
|
||||
prev.runtimeTelemetryVisible === next.runtimeTelemetryVisible &&
|
||||
prev.onRuntimeTelemetryHoverChange === next.onRuntimeTelemetryHoverChange &&
|
||||
prev.onRestartMember === next.onRestartMember &&
|
||||
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
||||
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
||||
|
|
@ -378,6 +502,8 @@ interface MemberCardRowProps {
|
|||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
isLaunchSettling?: boolean;
|
||||
runtimeTelemetryVisible?: boolean;
|
||||
runtimeTelemetryScale?: RuntimeTelemetryScale;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||
|
|
@ -412,6 +538,8 @@ const MemberCardRow = memo(function MemberCardRow({
|
|||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
isLaunchSettling,
|
||||
runtimeTelemetryVisible,
|
||||
runtimeTelemetryScale,
|
||||
onOpenTask,
|
||||
onMemberClick,
|
||||
onSendMessage,
|
||||
|
|
@ -461,6 +589,8 @@ const MemberCardRow = memo(function MemberCardRow({
|
|||
spawnLaunchState={spawnLaunchState}
|
||||
spawnRuntimeAlive={spawnRuntimeAlive}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
runtimeTelemetryVisible={runtimeTelemetryVisible}
|
||||
runtimeTelemetryScale={runtimeTelemetryScale}
|
||||
onOpenTask={currentTask ? handleOpenTask : undefined}
|
||||
onOpenReviewTask={reviewTask ? handleOpenReviewTask : undefined}
|
||||
onClick={handleClick}
|
||||
|
|
@ -584,6 +714,8 @@ export const MemberList = memo(function MemberList({
|
|||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
launchParams,
|
||||
runtimeTelemetryVisible = false,
|
||||
onRuntimeTelemetryHoverChange,
|
||||
onMemberClick,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
|
|
@ -610,6 +742,14 @@ export const MemberList = memo(function MemberList({
|
|||
return () => observer.disconnect();
|
||||
}, [handleResize]);
|
||||
|
||||
const handleRuntimeTelemetryMouseEnter = useCallback(() => {
|
||||
onRuntimeTelemetryHoverChange?.(true);
|
||||
}, [onRuntimeTelemetryHoverChange]);
|
||||
|
||||
const handleRuntimeTelemetryMouseLeave = useCallback(() => {
|
||||
onRuntimeTelemetryHoverChange?.(false);
|
||||
}, [onRuntimeTelemetryHoverChange]);
|
||||
|
||||
const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
|
||||
const activeMembers = useMemo(
|
||||
() =>
|
||||
|
|
@ -628,6 +768,10 @@ export const MemberList = memo(function MemberList({
|
|||
[activeMembers]
|
||||
);
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const runtimeTelemetryScale = useMemo(
|
||||
() => buildRuntimeTelemetryScale(activeMembers, memberRuntimeEntries),
|
||||
[activeMembers, memberRuntimeEntries]
|
||||
);
|
||||
// Pre-compute reviewer->task map to avoid O(n*n) scan per member.
|
||||
const reviewTaskByMember = useMemo(() => {
|
||||
const result = new Map<string, TeamTaskWithKanban>();
|
||||
|
|
@ -797,7 +941,12 @@ export const MemberList = memo(function MemberList({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-1">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col gap-1"
|
||||
onMouseEnter={handleRuntimeTelemetryMouseEnter}
|
||||
onMouseLeave={handleRuntimeTelemetryMouseLeave}
|
||||
>
|
||||
<div className={gridClass}>
|
||||
{activeMembers.map((member) => {
|
||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||
|
|
@ -868,6 +1017,8 @@ export const MemberList = memo(function MemberList({
|
|||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={leadActivity}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
runtimeTelemetryVisible={runtimeTelemetryVisible}
|
||||
runtimeTelemetryScale={runtimeTelemetryScale}
|
||||
onOpenTask={onOpenTask}
|
||||
onMemberClick={onMemberClick}
|
||||
onSendMessage={onSendMessage}
|
||||
|
|
@ -912,6 +1063,8 @@ export const MemberList = memo(function MemberList({
|
|||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={leadActivity}
|
||||
isLaunchSettling={false}
|
||||
runtimeTelemetryVisible={runtimeTelemetryVisible}
|
||||
runtimeTelemetryScale={runtimeTelemetryScale}
|
||||
onOpenTask={onOpenTask}
|
||||
onMemberClick={onMemberClick}
|
||||
onSendMessage={onSendMessage}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,9 @@ export function reconcilePendingRepliesByMember(
|
|||
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
||||
const latestReplyAt = latestReplyToUserByMember.get(memberName);
|
||||
const latestDurableSendAt = latestUserSentByMember.get(memberName);
|
||||
const threshold = latestDurableSendAt ?? sentAtMs;
|
||||
// Do not let an older persisted send make a previous reply clear a fresh optimistic wait.
|
||||
const threshold =
|
||||
latestDurableSendAt == null ? sentAtMs : Math.max(latestDurableSendAt, sentAtMs);
|
||||
if (latestReplyAt != null && latestReplyAt > threshold) {
|
||||
changed = true;
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -109,7 +109,21 @@ export const ScopeWarningBanner = ({
|
|||
: 'Needs review',
|
||||
}
|
||||
: null;
|
||||
const config = ledgerConfig ?? TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
|
||||
const workIntervalConfig: TierConfig | null =
|
||||
sourceKind !== 'ledger' && confidence.reason.toLowerCase().includes('workinterval')
|
||||
? {
|
||||
Icon: Info,
|
||||
border: 'border-blue-500/15',
|
||||
bg: 'bg-blue-500/5',
|
||||
accentColor: 'text-blue-400',
|
||||
title: 'Scoped by persisted work interval',
|
||||
detail:
|
||||
'The task start marker was not available in the session log, so the diff is scoped by the task work interval stored on the board.',
|
||||
badgeLabel: 'Interval scoped',
|
||||
}
|
||||
: null;
|
||||
const config =
|
||||
ledgerConfig ?? workIntervalConfig ?? TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
|
||||
const { Icon } = config;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,20 +8,22 @@ const DEFAULT_SPIN_DURATION_MS = 1000;
|
|||
|
||||
export type SyncedLoader2Props = ComponentProps<typeof Loader2> & {
|
||||
spinDurationMs?: number;
|
||||
spinning?: boolean;
|
||||
};
|
||||
|
||||
export const SyncedLoader2 = ({
|
||||
className,
|
||||
style,
|
||||
spinDurationMs = DEFAULT_SPIN_DURATION_MS,
|
||||
spinning = true,
|
||||
...props
|
||||
}: SyncedLoader2Props): React.JSX.Element => {
|
||||
const syncedStyle = useSyncedAnimationStyle(true, spinDurationMs);
|
||||
const syncedStyle = useSyncedAnimationStyle(spinning, spinDurationMs);
|
||||
|
||||
return (
|
||||
<Loader2
|
||||
{...props}
|
||||
className={cn('animate-spin', className)}
|
||||
className={cn(spinning && 'animate-spin', className)}
|
||||
style={{ ...syncedStyle, ...style }}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
// Sentry must be initialised before React renders.
|
||||
// Prepare Sentry before React renders. Actual init waits for telemetry config.
|
||||
initSentryRenderer();
|
||||
|
||||
let root: ReactDOM.Root | null = null;
|
||||
|
|
|
|||
|
|
@ -10,20 +10,78 @@
|
|||
import * as SentryElectron from '@sentry/electron/renderer';
|
||||
import { browserTracingIntegration as reactBrowserTracing, init as reactInit } from '@sentry/react';
|
||||
import {
|
||||
filterSafeSentryIntegrations,
|
||||
isValidDsn,
|
||||
redactSentryEvent,
|
||||
SENTRY_ENVIRONMENT,
|
||||
SENTRY_RELEASE,
|
||||
TRACES_SAMPLE_RATE,
|
||||
} from '@shared/utils/sentryConfig';
|
||||
|
||||
import type { ElectronAPI } from '@shared/types/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry gate (mirrors src/main/sentry.ts pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Defaults to `true` so early renderer crashes are captured.
|
||||
// Synced to user's telemetryEnabled preference via syncRendererTelemetry().
|
||||
let telemetryAllowed = true;
|
||||
// Start closed until persisted config is loaded through the store.
|
||||
let telemetryAllowed = false;
|
||||
let initialized = false;
|
||||
let telemetryIdentitySyncToken = 0;
|
||||
|
||||
function getElectronApi(): ElectronAPI | undefined {
|
||||
return (window as Window & { electronAPI?: ElectronAPI }).electronAPI;
|
||||
}
|
||||
|
||||
function clearRendererSentryUser(): void {
|
||||
if (!initialized) return;
|
||||
SentryElectron.setUser?.(null);
|
||||
}
|
||||
|
||||
async function syncRendererTelemetryIdentity(): Promise<void> {
|
||||
const syncToken = ++telemetryIdentitySyncToken;
|
||||
if (!initialized || !telemetryAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getSentryContext = getElectronApi()?.telemetry?.getSentryContext;
|
||||
if (!getSentryContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await getSentryContext();
|
||||
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
SentryElectron.setUser?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
SentryElectron.setUser?.({ id: context.userId });
|
||||
SentryElectron.setTags?.(context.tags);
|
||||
} catch {
|
||||
if (syncToken === telemetryIdentitySyncToken) {
|
||||
SentryElectron.setUser?.(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSafeRendererErrorContext(
|
||||
context?: Record<string, unknown>
|
||||
): Record<string, unknown> | null {
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
activeTabType: typeof context.activeTabType === 'string' ? context.activeTabType : null,
|
||||
hasComponentStack:
|
||||
typeof context.componentStack === 'string' && context.componentStack.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the opt-in flag from config. Call after config is loaded
|
||||
|
|
@ -31,13 +89,18 @@ let initialized = false;
|
|||
*/
|
||||
export function syncRendererTelemetry(enabled: boolean): void {
|
||||
telemetryAllowed = enabled;
|
||||
if (!enabled && initialized && typeof SentryElectron.setUser === 'function') {
|
||||
SentryElectron.setUser(null);
|
||||
if (!enabled) {
|
||||
telemetryIdentitySyncToken++;
|
||||
clearRendererSentryUser();
|
||||
return;
|
||||
}
|
||||
|
||||
initSentryRenderer();
|
||||
void syncRendererTelemetryIdentity();
|
||||
}
|
||||
|
||||
export function initSentryRenderer(): void {
|
||||
if (initialized) return;
|
||||
if (initialized || !telemetryAllowed) return;
|
||||
|
||||
const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined;
|
||||
if (!isValidDsn(dsn)) return;
|
||||
|
|
@ -51,31 +114,39 @@ export function initSentryRenderer(): void {
|
|||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cross-version @sentry/core type mismatch
|
||||
const beforeSend = (event: any): any => (telemetryAllowed ? event : null);
|
||||
const beforeSend = (event: any): any => (telemetryAllowed ? redactSentryEvent(event) : null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cross-version @sentry/core type mismatch
|
||||
const beforeSendTransaction = (event: any): any => (telemetryAllowed ? event : null);
|
||||
const beforeSendTransaction = (event: any): any =>
|
||||
telemetryAllowed ? redactSentryEvent(event) : null;
|
||||
|
||||
if (window.electronAPI) {
|
||||
// Electron renderer — uses IPC transport to main process.
|
||||
if (getElectronApi()) {
|
||||
// Electron renderer - uses IPC transport to main process.
|
||||
// browserTracingIntegration from @sentry/electron/renderer to avoid
|
||||
// @sentry/core version mismatch with @sentry/react.
|
||||
SentryElectron.init({
|
||||
...baseOptions,
|
||||
beforeSend,
|
||||
beforeSendTransaction,
|
||||
integrations: [SentryElectron.browserTracingIntegration()],
|
||||
integrations: (integrations) => [
|
||||
...filterSafeSentryIntegrations(integrations),
|
||||
SentryElectron.browserTracingIntegration(),
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// Standalone browser mode — direct HTTP transport
|
||||
// Standalone browser mode - direct HTTP transport
|
||||
reactInit({
|
||||
...baseOptions,
|
||||
beforeSend,
|
||||
beforeSendTransaction,
|
||||
integrations: [reactBrowserTracing()],
|
||||
integrations: (integrations) => [
|
||||
...filterSafeSentryIntegrations(integrations),
|
||||
reactBrowserTracing(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
void syncRendererTelemetryIdentity();
|
||||
}
|
||||
|
||||
/** Whether the renderer SDK was successfully initialised. */
|
||||
|
|
@ -88,11 +159,11 @@ export function isSentryRendererActive(): boolean {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Record a navigation breadcrumb (tab switches). */
|
||||
export function addNavigationBreadcrumb(from: string, to: string): void {
|
||||
export function addNavigationBreadcrumb(_from: string, _to: string): void {
|
||||
if (!initialized) return;
|
||||
SentryElectron.addBreadcrumb({
|
||||
category: 'navigation',
|
||||
message: `Tab: ${from} → ${to}`,
|
||||
message: 'tab-change',
|
||||
level: 'info',
|
||||
});
|
||||
}
|
||||
|
|
@ -101,17 +172,18 @@ export function addNavigationBreadcrumb(from: string, to: string): void {
|
|||
export function addRendererBreadcrumb(
|
||||
category: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
_data?: Record<string, unknown>
|
||||
): void {
|
||||
if (!initialized) return;
|
||||
SentryElectron.addBreadcrumb({ category, message, data, level: 'info' });
|
||||
SentryElectron.addBreadcrumb({ category, message, level: 'info' });
|
||||
}
|
||||
|
||||
/** Capture an exception with optional extra context. */
|
||||
export function captureRendererException(error: Error, context?: Record<string, unknown>): void {
|
||||
if (!initialized) return;
|
||||
SentryElectron.withScope((scope) => {
|
||||
if (context) scope.setContext('react', context);
|
||||
const safeContext = getSafeRendererErrorContext(context);
|
||||
if (safeContext) scope.setContext('react', safeContext);
|
||||
SentryElectron.captureException(error);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
|
|
@ -25,32 +26,29 @@ const OPENCODE_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
|
|||
const CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3;
|
||||
const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
|
||||
|
||||
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = [
|
||||
'anthropic',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode',
|
||||
];
|
||||
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = isGeminiUiFrozen()
|
||||
? ['anthropic', 'codex', 'opencode']
|
||||
: ['anthropic', 'codex', 'gemini', 'opencode'];
|
||||
const MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(MULTIMODEL_PROVIDER_IDS);
|
||||
|
||||
function isActiveMultimodelProviderId(providerId: CliProviderId): boolean {
|
||||
return MULTIMODEL_PROVIDER_ID_SET.has(providerId);
|
||||
}
|
||||
|
||||
export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
||||
const providers: CliProviderStatus[] = (
|
||||
[
|
||||
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
||||
{ providerId: 'codex', displayName: 'Codex' },
|
||||
{ providerId: 'gemini', displayName: 'Gemini' },
|
||||
{ providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
...provider,
|
||||
const providers: CliProviderStatus[] = MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
|
||||
providerId,
|
||||
displayName: getProviderDisplayName(providerId),
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown' as const,
|
||||
modelVerificationState: 'idle' as const,
|
||||
modelCatalogRefreshState: 'idle' as const,
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: provider.providerId !== 'opencode',
|
||||
canLoginFromUi: providerId !== 'opencode',
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
|
|
@ -78,7 +76,11 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
|||
};
|
||||
}
|
||||
|
||||
function isModelOnlyFallbackProviderStatus(provider: CliProviderStatus): boolean {
|
||||
function isModelOnlyFallbackProviderStatus(provider: CliProviderStatus | undefined): boolean {
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
provider.supported === false &&
|
||||
provider.authenticated === false &&
|
||||
|
|
@ -172,6 +174,25 @@ function shouldPreserveCurrentProviderStatus(
|
|||
);
|
||||
}
|
||||
|
||||
function mergeProviderCatalogCache(
|
||||
incomingProvider: CliProviderStatus,
|
||||
currentProvider: CliProviderStatus
|
||||
): CliProviderStatus {
|
||||
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
|
||||
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
|
||||
return {
|
||||
...incomingProvider,
|
||||
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
|
||||
modelCatalog,
|
||||
modelCatalogRefreshState:
|
||||
modelCatalog && incomingRefreshState !== 'error'
|
||||
? 'ready'
|
||||
: (incomingRefreshState ?? currentProvider.modelCatalogRefreshState),
|
||||
runtimeCapabilities:
|
||||
incomingProvider.runtimeCapabilities ?? currentProvider.runtimeCapabilities ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getIncompleteMultimodelProviderIds(
|
||||
status: CliInstallationStatus | null
|
||||
): CliProviderId[] {
|
||||
|
|
@ -180,7 +201,11 @@ export function getIncompleteMultimodelProviderIds(
|
|||
}
|
||||
|
||||
return status.providers
|
||||
.filter((provider) => !isHydratedMultimodelProviderStatus(provider))
|
||||
.filter(
|
||||
(provider) =>
|
||||
isActiveMultimodelProviderId(provider.providerId) &&
|
||||
!isHydratedMultimodelProviderStatus(provider)
|
||||
)
|
||||
.map((provider) => provider.providerId);
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +217,11 @@ export function getModelOnlyFallbackProviderIds(
|
|||
}
|
||||
|
||||
return status.providers
|
||||
.filter((provider) => isModelOnlyFallbackProviderStatus(provider))
|
||||
.filter(
|
||||
(provider) =>
|
||||
isActiveMultimodelProviderId(provider.providerId) &&
|
||||
isModelOnlyFallbackProviderStatus(provider)
|
||||
)
|
||||
.map((provider) => provider.providerId);
|
||||
}
|
||||
|
||||
|
|
@ -205,12 +234,20 @@ export function reconcileMultimodelProviderLoading(
|
|||
}
|
||||
|
||||
const incompleteProviderIds = new Set(getIncompleteMultimodelProviderIds(status));
|
||||
return status.providers.reduce<Partial<Record<CliProviderId, boolean>>>(
|
||||
(nextLoading, provider) => ({
|
||||
...nextLoading,
|
||||
[provider.providerId]: incompleteProviderIds.has(provider.providerId),
|
||||
}),
|
||||
{ ...currentLoading }
|
||||
const providersById = new Map(
|
||||
status.providers.map((provider) => [provider.providerId, provider])
|
||||
);
|
||||
return MULTIMODEL_PROVIDER_IDS.reduce<Partial<Record<CliProviderId, boolean>>>(
|
||||
(nextLoading, providerId) => {
|
||||
const provider = providersById.get(providerId);
|
||||
return {
|
||||
...nextLoading,
|
||||
[providerId]: provider
|
||||
? incompleteProviderIds.has(providerId)
|
||||
: currentLoading[providerId] === true,
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +346,7 @@ function areProviderStatusContentEqual(a: CliProviderStatus, b: CliProviderStatu
|
|||
a.authMethod === b.authMethod &&
|
||||
a.verificationState === b.verificationState &&
|
||||
(a.modelVerificationState ?? null) === (b.modelVerificationState ?? null) &&
|
||||
(a.modelCatalogRefreshState ?? null) === (b.modelCatalogRefreshState ?? null) &&
|
||||
(a.statusMessage ?? null) === (b.statusMessage ?? null) &&
|
||||
(a.detailMessage ?? null) === (b.detailMessage ?? null) &&
|
||||
a.canLoginFromUi === b.canLoginFromUi &&
|
||||
|
|
@ -374,7 +412,7 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
return incomingProvider;
|
||||
}
|
||||
if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
|
||||
return currentProvider;
|
||||
return mergeProviderCatalogCache(incomingProvider, currentProvider);
|
||||
}
|
||||
// Preserve the current reference when content is identical so the
|
||||
// providers array stays reference-stable across steady-state IPC polls.
|
||||
|
|
@ -387,13 +425,14 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
for (const currentProvider of current.providers) {
|
||||
if (
|
||||
!incomingProviderIds.has(currentProvider.providerId) &&
|
||||
isActiveMultimodelProviderId(currentProvider.providerId) &&
|
||||
isHydratedMultimodelProviderStatus(currentProvider)
|
||||
) {
|
||||
providers.push(currentProvider);
|
||||
}
|
||||
}
|
||||
|
||||
const authenticatedProvider = providers.find((provider) => provider.authenticated) ?? null;
|
||||
const authenticatedProvider = getAuthenticatedProvider(providers);
|
||||
|
||||
const mergedProviders = areArraysEqual(providers, current.providers, Object.is)
|
||||
? current.providers
|
||||
|
|
@ -402,7 +441,9 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
const merged: CliInstallationStatus = {
|
||||
...incoming,
|
||||
providers: mergedProviders,
|
||||
authLoggedIn: mergedProviders.some((provider) => provider.authenticated),
|
||||
authLoggedIn: mergedProviders.some(
|
||||
(provider) => isActiveMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
};
|
||||
|
||||
|
|
@ -468,11 +509,15 @@ function isMultimodelCliStatus(
|
|||
function hasActiveProviderStatusLoading(
|
||||
providerLoading: Partial<Record<CliProviderId, boolean>>
|
||||
): boolean {
|
||||
return Object.values(providerLoading).some((loading) => loading === true);
|
||||
return MULTIMODEL_PROVIDER_IDS.some((providerId) => providerLoading[providerId] === true);
|
||||
}
|
||||
|
||||
function getAuthenticatedProvider(providers: CliProviderStatus[]): CliProviderStatus | null {
|
||||
return providers.find((provider) => provider.authenticated) ?? null;
|
||||
return (
|
||||
providers.find(
|
||||
(provider) => isActiveMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildMultimodelCliAuthState(params: {
|
||||
|
|
@ -485,7 +530,9 @@ function buildMultimodelCliAuthState(params: {
|
|||
const authenticatedProvider = getAuthenticatedProvider(providers);
|
||||
|
||||
return {
|
||||
authLoggedIn: providers.some((provider) => provider.authenticated),
|
||||
authLoggedIn: providers.some(
|
||||
(provider) => isActiveMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
authStatusChecking: params.status.installed && hasActiveProviderStatusLoading(providerLoading),
|
||||
};
|
||||
|
|
@ -513,7 +560,27 @@ function createProviderStatusErrorSnapshot(params: {
|
|||
params.currentProvider ??
|
||||
createLoadingMultimodelCliStatus().providers.find(
|
||||
(provider) => provider.providerId === params.providerId
|
||||
)!;
|
||||
) ??
|
||||
({
|
||||
providerId: params.providerId,
|
||||
displayName: getProviderDisplayName(params.providerId),
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
modelCatalogRefreshState: 'idle',
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: params.providerId !== 'opencode',
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
backend: null,
|
||||
} satisfies CliProviderStatus);
|
||||
|
||||
return {
|
||||
...currentProvider,
|
||||
|
|
@ -522,6 +589,7 @@ function createProviderStatusErrorSnapshot(params: {
|
|||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
modelCatalogRefreshState: 'error',
|
||||
statusMessage: params.message,
|
||||
detailMessage: null,
|
||||
};
|
||||
|
|
@ -764,6 +832,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
});
|
||||
if (status.installed) {
|
||||
for (const provider of status.providers) {
|
||||
if (!isActiveMultimodelProviderId(provider.providerId)) {
|
||||
continue;
|
||||
}
|
||||
void get().fetchCliProviderStatus(provider.providerId, {
|
||||
silent: true,
|
||||
epoch,
|
||||
|
|
@ -848,12 +919,30 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
}
|
||||
|
||||
const settledCliStatus: CliInstallationStatus = currentCliStatus;
|
||||
if (
|
||||
isMultimodelCliStatus(settledCliStatus) &&
|
||||
!isActiveMultimodelProviderId(providerId)
|
||||
) {
|
||||
return {
|
||||
cliProviderStatusLoading: nextLoading,
|
||||
cliStatus: {
|
||||
...settledCliStatus,
|
||||
...buildMultimodelCliAuthState({
|
||||
status: settledCliStatus,
|
||||
providerLoading: nextLoading,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hasProvider = settledCliStatus.providers.some(
|
||||
(provider) => provider.providerId === providerId
|
||||
);
|
||||
const nextProviders = hasProvider
|
||||
? settledCliStatus.providers.map((provider) =>
|
||||
provider.providerId === providerId ? providerStatus : provider
|
||||
provider.providerId === providerId
|
||||
? mergeProviderCatalogCache(providerStatus, provider)
|
||||
: provider
|
||||
)
|
||||
: [...settledCliStatus.providers, providerStatus];
|
||||
const nextCliStatus = isMultimodelCliStatus(settledCliStatus)
|
||||
|
|
@ -906,6 +995,22 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
}
|
||||
|
||||
const settledCliStatus: CliInstallationStatus = currentCliStatus;
|
||||
if (
|
||||
isMultimodelCliStatus(settledCliStatus) &&
|
||||
!isActiveMultimodelProviderId(providerId)
|
||||
) {
|
||||
return {
|
||||
cliProviderStatusLoading: nextLoading,
|
||||
cliStatus: {
|
||||
...settledCliStatus,
|
||||
...buildMultimodelCliAuthState({
|
||||
status: settledCliStatus,
|
||||
providerLoading: nextLoading,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const currentProvider =
|
||||
settledCliStatus.providers.find((provider) => provider.providerId === providerId) ??
|
||||
undefined;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import type {
|
|||
TaskChangePresenceState,
|
||||
TaskComment,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamAgentRuntimeResourceSample,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
TeamCreateRequest,
|
||||
TeamGetDataOptions,
|
||||
|
|
@ -978,16 +979,44 @@ function maybeLogMemberSpawnUiEqualSuppressed(
|
|||
);
|
||||
}
|
||||
|
||||
function isTeamAgentRuntimeResourceSampleLike(
|
||||
value: unknown
|
||||
): value is TeamAgentRuntimeResourceSample {
|
||||
return Boolean(value) && typeof value === 'object';
|
||||
}
|
||||
|
||||
function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean {
|
||||
if (left === right) return true;
|
||||
if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
left.timestamp === right.timestamp &&
|
||||
left.cpuPercent === right.cpuPercent &&
|
||||
left.rssBytes === right.rssBytes &&
|
||||
left.primaryCpuPercent === right.primaryCpuPercent &&
|
||||
left.primaryRssBytes === right.primaryRssBytes &&
|
||||
left.childCpuPercent === right.childCpuPercent &&
|
||||
left.childRssBytes === right.childRssBytes &&
|
||||
left.processCount === right.processCount &&
|
||||
left.runtimeLoadScope === right.runtimeLoadScope &&
|
||||
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
|
||||
left.pidSource === right.pidSource &&
|
||||
left.pid === right.pid &&
|
||||
left.runtimePid === right.runtimePid
|
||||
);
|
||||
}
|
||||
|
||||
function areTeamAgentRuntimeEntriesEqual(
|
||||
left: TeamAgentRuntimeEntry | undefined,
|
||||
right: TeamAgentRuntimeEntry | undefined
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
const leftDiagnostics = left.diagnostics ?? [];
|
||||
const rightDiagnostics = right.diagnostics ?? [];
|
||||
const leftResourceHistory = left.resourceHistory ?? [];
|
||||
const rightResourceHistory = right.resourceHistory ?? [];
|
||||
const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : [];
|
||||
const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : [];
|
||||
const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : [];
|
||||
const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : [];
|
||||
return (
|
||||
left.memberName === right.memberName &&
|
||||
left.alive === right.alive &&
|
||||
|
|
@ -1001,6 +1030,13 @@ function areTeamAgentRuntimeEntriesEqual(
|
|||
left.runtimeModel === right.runtimeModel &&
|
||||
left.rssBytes === right.rssBytes &&
|
||||
left.cpuPercent === right.cpuPercent &&
|
||||
left.primaryCpuPercent === right.primaryCpuPercent &&
|
||||
left.primaryRssBytes === right.primaryRssBytes &&
|
||||
left.childCpuPercent === right.childCpuPercent &&
|
||||
left.childRssBytes === right.childRssBytes &&
|
||||
left.processCount === right.processCount &&
|
||||
left.runtimeLoadScope === right.runtimeLoadScope &&
|
||||
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
|
||||
left.livenessKind === right.livenessKind &&
|
||||
left.pidSource === right.pidSource &&
|
||||
left.processCommand === right.processCommand &&
|
||||
|
|
@ -1016,17 +1052,9 @@ function areTeamAgentRuntimeEntriesEqual(
|
|||
leftDiagnostics.length === rightDiagnostics.length &&
|
||||
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
|
||||
leftResourceHistory.length === rightResourceHistory.length &&
|
||||
leftResourceHistory.every((value, index) => {
|
||||
const other = rightResourceHistory[index];
|
||||
return (
|
||||
value.timestamp === other?.timestamp &&
|
||||
value.cpuPercent === other?.cpuPercent &&
|
||||
value.rssBytes === other?.rssBytes &&
|
||||
value.pidSource === other?.pidSource &&
|
||||
value.pid === other?.pid &&
|
||||
value.runtimePid === other?.runtimePid
|
||||
);
|
||||
})
|
||||
leftResourceHistory.every((value, index) =>
|
||||
areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -304,10 +304,64 @@ function formatRetryCountdown(ms: number): string {
|
|||
return `${totalSeconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
}
|
||||
const seconds = totalSeconds % 60;
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
|
||||
function getRuntimeAdvisoryRetryRemainingMs(
|
||||
advisory: MemberRuntimeAdvisory,
|
||||
nowMs: number
|
||||
): number | null {
|
||||
const retryUntilMs = advisory.retryUntil ? Date.parse(advisory.retryUntil) : Number.NaN;
|
||||
if (!Number.isFinite(retryUntilMs)) {
|
||||
return null;
|
||||
}
|
||||
const remainingMs = retryUntilMs - nowMs;
|
||||
return remainingMs > 0 ? remainingMs : null;
|
||||
}
|
||||
|
||||
function isRetryTimedApiAdvisory(
|
||||
advisory: MemberRuntimeAdvisory,
|
||||
providerId: TeamProviderId | undefined
|
||||
): boolean {
|
||||
return (
|
||||
advisory.kind === 'api_error' &&
|
||||
providerId === 'opencode' &&
|
||||
(advisory.reasonCode === 'quota_exhausted' || advisory.reasonCode === 'rate_limited')
|
||||
);
|
||||
}
|
||||
|
||||
function formatRetryUntilUtc(value: string | undefined): string | null {
|
||||
const retryUntilMs = value ? Date.parse(value) : Number.NaN;
|
||||
if (!Number.isFinite(retryUntilMs)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(retryUntilMs);
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes} UTC`;
|
||||
}
|
||||
|
||||
function appendRuntimeAdvisoryRetryHint(
|
||||
base: string,
|
||||
advisory: MemberRuntimeAdvisory,
|
||||
providerId: TeamProviderId | undefined
|
||||
): string {
|
||||
if (!isRetryTimedApiAdvisory(advisory, providerId)) {
|
||||
return base;
|
||||
}
|
||||
const retryAt = formatRetryUntilUtc(advisory.retryUntil);
|
||||
if (!retryAt) {
|
||||
return base;
|
||||
}
|
||||
return `${base} Waiting for OpenCode retry or quota reset around ${retryAt}.`;
|
||||
}
|
||||
|
||||
function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined): string | null {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
|
|
@ -461,12 +515,20 @@ function formatRuntimeAdvisoryTitle(
|
|||
switch (advisory.reasonCode) {
|
||||
case 'quota_exhausted':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
`${providerLabel ?? 'Provider'} quota exhausted.`,
|
||||
appendRuntimeAdvisoryRetryHint(
|
||||
`${providerLabel ?? 'Provider'} quota exhausted.`,
|
||||
advisory,
|
||||
providerId
|
||||
),
|
||||
advisory.message
|
||||
);
|
||||
case 'rate_limited':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
`${providerLabel ?? 'Provider'} rate limited the request.`,
|
||||
appendRuntimeAdvisoryRetryHint(
|
||||
`${providerLabel ?? 'Provider'} rate limited the request.`,
|
||||
advisory,
|
||||
providerId
|
||||
),
|
||||
advisory.message
|
||||
);
|
||||
case 'auth_error':
|
||||
|
|
@ -584,18 +646,17 @@ export function getMemberRuntimeAdvisoryLabel(
|
|||
return null;
|
||||
}
|
||||
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
|
||||
const remainingMs = getRuntimeAdvisoryRetryRemainingMs(advisory, nowMs);
|
||||
if (advisory.kind === 'api_error') {
|
||||
if (remainingMs && isRetryTimedApiAdvisory(advisory, providerId)) {
|
||||
return `${baseLabel} · retry ${formatRetryCountdown(remainingMs)}`;
|
||||
}
|
||||
return baseLabel;
|
||||
}
|
||||
if (advisory.kind !== 'sdk_retrying') {
|
||||
return null;
|
||||
}
|
||||
const retryUntilMs = advisory.retryUntil ? Date.parse(advisory.retryUntil) : Number.NaN;
|
||||
if (!Number.isFinite(retryUntilMs)) {
|
||||
return baseLabel;
|
||||
}
|
||||
const remainingMs = retryUntilMs - nowMs;
|
||||
if (remainingMs <= 0) {
|
||||
if (!remainingMs) {
|
||||
return baseLabel;
|
||||
}
|
||||
return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
MemberLaunchState,
|
||||
MemberRuntimeAdvisory,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
MemberSpawnStatusEntry,
|
||||
|
|
@ -49,6 +50,11 @@ export interface MemberLaunchDiagnosticsPayload {
|
|||
rssBytes?: number;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
runtimeAdvisoryKind?: MemberRuntimeAdvisory['kind'];
|
||||
runtimeAdvisoryReasonCode?: MemberRuntimeAdvisory['reasonCode'];
|
||||
runtimeAdvisoryObservedAt?: string;
|
||||
runtimeAdvisoryRetryUntil?: string;
|
||||
runtimeAdvisoryRetryDelayMs?: number;
|
||||
bootstrapStalled?: boolean;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
firstSpawnAcceptedAt?: string;
|
||||
|
|
@ -240,25 +246,39 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
|||
livenessSource?: MemberSpawnLivenessSource;
|
||||
spawnEntry?: MemberSpawnStatusEntry;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
runtimeAdvisory?: MemberRuntimeAdvisory;
|
||||
runtimeAdvisoryLabel?: string | null;
|
||||
runtimeAdvisoryTitle?: string;
|
||||
}): MemberLaunchDiagnosticsPayload {
|
||||
const spawnEntry = params.spawnEntry;
|
||||
const runtimeEntry = params.runtimeEntry;
|
||||
const runtimeAdvisory = params.runtimeAdvisory;
|
||||
const runtimeAdvisoryTitle = boundedString(params.runtimeAdvisoryTitle);
|
||||
const runtimeAdvisoryLabel = boundedString(params.runtimeAdvisoryLabel ?? undefined);
|
||||
const runtimeAdvisoryMessage = boundedString(runtimeAdvisory?.message);
|
||||
const runtimeAdvisoryCardError =
|
||||
runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage;
|
||||
const runtimeDiagnostic =
|
||||
boundedString(spawnEntry?.runtimeDiagnostic) ??
|
||||
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
||||
boundedString(spawnEntry?.hardFailureReason) ??
|
||||
boundedString(spawnEntry?.error);
|
||||
const memberCardError = boundedString(
|
||||
normalizeMemberLaunchFailureReason(
|
||||
spawnEntry?.error ??
|
||||
spawnEntry?.hardFailureReason ??
|
||||
spawnEntry?.runtimeDiagnostic ??
|
||||
runtimeEntry?.runtimeDiagnostic
|
||||
) ?? undefined
|
||||
);
|
||||
boundedString(spawnEntry?.error) ??
|
||||
runtimeAdvisoryMessage;
|
||||
const memberCardError =
|
||||
boundedString(
|
||||
normalizeMemberLaunchFailureReason(
|
||||
spawnEntry?.error ??
|
||||
spawnEntry?.hardFailureReason ??
|
||||
spawnEntry?.runtimeDiagnostic ??
|
||||
runtimeEntry?.runtimeDiagnostic
|
||||
) ?? undefined
|
||||
) ?? runtimeAdvisoryCardError;
|
||||
const diagnostics = uniqueDiagnostics(
|
||||
memberCardError ? [memberCardError] : undefined,
|
||||
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
||||
runtimeAdvisoryTitle ? [runtimeAdvisoryTitle] : undefined,
|
||||
runtimeAdvisoryLabel ? [runtimeAdvisoryLabel] : undefined,
|
||||
runtimeAdvisoryMessage ? [runtimeAdvisoryMessage] : undefined,
|
||||
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
||||
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
||||
runtimeEntry?.diagnostics
|
||||
|
|
@ -370,6 +390,19 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
|||
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
|
||||
}
|
||||
: {}),
|
||||
...(runtimeAdvisory?.kind ? { runtimeAdvisoryKind: runtimeAdvisory.kind } : {}),
|
||||
...(runtimeAdvisory?.reasonCode
|
||||
? { runtimeAdvisoryReasonCode: runtimeAdvisory.reasonCode }
|
||||
: {}),
|
||||
...(maybeString(runtimeAdvisory?.observedAt)
|
||||
? { runtimeAdvisoryObservedAt: maybeString(runtimeAdvisory?.observedAt) }
|
||||
: {}),
|
||||
...(maybeString(runtimeAdvisory?.retryUntil)
|
||||
? { runtimeAdvisoryRetryUntil: maybeString(runtimeAdvisory?.retryUntil) }
|
||||
: {}),
|
||||
...(boundedNumber(runtimeAdvisory?.retryDelayMs)
|
||||
? { runtimeAdvisoryRetryDelayMs: boundedNumber(runtimeAdvisory?.retryDelayMs) }
|
||||
: {}),
|
||||
...(spawnEntry?.bootstrapStalled === true ? { bootstrapStalled: true } : {}),
|
||||
...(boundedStringArray(spawnEntry?.pendingPermissionRequestIds)
|
||||
? { pendingPermissionRequestIds: boundedStringArray(spawnEntry?.pendingPermissionRequestIds) }
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ function appendRuntimeSummarySuffixes(
|
|||
export function getRuntimeMemorySourceLabel(
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||
): string | undefined {
|
||||
if (runtimeEntry?.runtimeLoadScope === 'shared-host') {
|
||||
return 'RSS source: shared OpenCode host';
|
||||
}
|
||||
if (!runtimeEntry?.pidSource) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
73
src/renderer/vendor/radixComposeRefs.ts
vendored
Normal file
73
src/renderer/vendor/radixComposeRefs.ts
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import * as React from 'react';
|
||||
|
||||
type PossibleRef<T> = React.Ref<T> | undefined;
|
||||
|
||||
function setRef<T>(ref: PossibleRef<T>, value: T | null): void | (() => void) {
|
||||
if (typeof ref === 'function') {
|
||||
return ref(value);
|
||||
}
|
||||
|
||||
if (ref !== null && ref !== undefined) {
|
||||
(ref as React.MutableRefObject<T | null>).current = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
return (node) => {
|
||||
let hasCleanup = false;
|
||||
const cleanups = refs.map((ref) => {
|
||||
const cleanup = setRef(ref, node);
|
||||
if (!hasCleanup && typeof cleanup === 'function') {
|
||||
hasCleanup = true;
|
||||
}
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
if (hasCleanup) {
|
||||
return () => {
|
||||
for (let index = 0; index < cleanups.length; index += 1) {
|
||||
const cleanup = cleanups[index];
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup();
|
||||
} else {
|
||||
setRef(refs[index], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
const refsRef = React.useRef(refs);
|
||||
refsRef.current = refs;
|
||||
|
||||
return React.useCallback((node) => {
|
||||
const currentRefs = refsRef.current;
|
||||
let hasCleanup = false;
|
||||
const cleanups = currentRefs.map((ref) => {
|
||||
const cleanup = setRef(ref, node);
|
||||
if (!hasCleanup && typeof cleanup === 'function') {
|
||||
hasCleanup = true;
|
||||
}
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
if (hasCleanup) {
|
||||
return () => {
|
||||
for (let index = 0; index < cleanups.length; index += 1) {
|
||||
const cleanup = cleanups[index];
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup();
|
||||
} else {
|
||||
setRef(currentRefs[index], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -790,6 +790,19 @@ export interface ReviewAPI {
|
|||
) => Promise<{ hash: string; timestamp: string; message: string }[]>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Telemetry API
|
||||
// =============================================================================
|
||||
|
||||
export interface SentryTelemetryContext {
|
||||
userId: string;
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TelemetryAPI {
|
||||
getSentryContext: () => Promise<SentryTelemetryContext | null>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Electron API
|
||||
// =============================================================================
|
||||
|
|
@ -799,6 +812,7 @@ export interface ReviewAPI {
|
|||
*/
|
||||
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
|
||||
startup?: AppStartupAPI;
|
||||
telemetry: TelemetryAPI;
|
||||
getAppVersion: () => Promise<string>;
|
||||
getProjects: () => Promise<Project[]>;
|
||||
getSessions: (projectId: string) => Promise<Session[]>;
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ export interface CliProviderStatus {
|
|||
detailMessage?: string | null;
|
||||
models: string[];
|
||||
modelCatalog?: CliProviderModelCatalog | null;
|
||||
modelCatalogRefreshState?: 'idle' | 'loading' | 'ready' | 'error';
|
||||
modelAvailability?: CliProviderModelAvailability[];
|
||||
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
|
||||
subscriptionRateLimits?: CliProviderSubscriptionRateLimitSnapshot | null;
|
||||
|
|
|
|||
|
|
@ -1203,11 +1203,20 @@ export interface TeamAgentRuntimeResourceSample {
|
|||
timestamp: string;
|
||||
cpuPercent?: number;
|
||||
rssBytes?: number;
|
||||
primaryCpuPercent?: number;
|
||||
primaryRssBytes?: number;
|
||||
childCpuPercent?: number;
|
||||
childRssBytes?: number;
|
||||
processCount?: number;
|
||||
runtimeLoadScope?: TeamAgentRuntimeLoadScope;
|
||||
runtimeLoadTruncated?: boolean;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
pid?: number;
|
||||
runtimePid?: number;
|
||||
}
|
||||
|
||||
export type TeamAgentRuntimeLoadScope = 'single-process' | 'process-tree' | 'shared-host';
|
||||
|
||||
export interface TeamAgentRuntimeEntry {
|
||||
memberName: string;
|
||||
alive: boolean;
|
||||
|
|
@ -1223,6 +1232,13 @@ export interface TeamAgentRuntimeEntry {
|
|||
cwd?: string;
|
||||
rssBytes?: number;
|
||||
cpuPercent?: number;
|
||||
primaryCpuPercent?: number;
|
||||
primaryRssBytes?: number;
|
||||
childCpuPercent?: number;
|
||||
childRssBytes?: number;
|
||||
processCount?: number;
|
||||
runtimeLoadScope?: TeamAgentRuntimeLoadScope;
|
||||
runtimeLoadTruncated?: boolean;
|
||||
resourceHistory?: TeamAgentRuntimeResourceSample[];
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
|
|
@ -1307,6 +1323,12 @@ export interface MemberSpawnStatusEntry {
|
|||
hardFailure?: boolean;
|
||||
/** Pending runtime permission request ids currently blocking bootstrap. */
|
||||
pendingPermissionRequestIds?: string[];
|
||||
/** OpenCode bootstrap evidence source for launch/status recovery. */
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
/** OpenCode bootstrap proof mode. Missing means app-managed for current OpenCode sessions. */
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
/** Candidate used by app-managed OpenCode bootstrap before durable evidence promotion. */
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
/** ISO timestamp of the first accepted teammate spawn for this member. */
|
||||
firstSpawnAcceptedAt?: string;
|
||||
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */
|
||||
|
|
|
|||
41
src/shared/utils/__tests__/sentryConfig.test.ts
Normal file
41
src/shared/utils/__tests__/sentryConfig.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { filterSafeSentryIntegrations, redactSentryEvent } from '../sentryConfig';
|
||||
|
||||
describe('sentryConfig privacy helpers', () => {
|
||||
it('redacts high-risk event data recursively', () => {
|
||||
const event = redactSentryEvent({
|
||||
message: 'token sk-secretsecretsecret at /Users/alice/work/private-repo',
|
||||
user: {
|
||||
email: 'dev@example.com',
|
||||
},
|
||||
extra: {
|
||||
accountUuid: 'd9b2d63a-582c-4d69-8a01-90e8199f532d',
|
||||
nested: [{ projectPath: '/home/bob/repo' }],
|
||||
},
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(event);
|
||||
expect(serialized).not.toContain('sk-secretsecretsecret');
|
||||
expect(serialized).not.toContain('/Users/alice');
|
||||
expect(serialized).not.toContain('private-repo');
|
||||
expect(serialized).not.toContain('dev@example.com');
|
||||
expect(serialized).not.toContain('d9b2d63a-582c-4d69-8a01-90e8199f532d');
|
||||
expect(serialized).not.toContain('/home/bob');
|
||||
});
|
||||
|
||||
it('filters default integrations that may collect PII-heavy context', () => {
|
||||
expect(
|
||||
filterSafeSentryIntegrations([
|
||||
{ name: 'MainProcessSession' },
|
||||
{ name: 'OnUncaughtException' },
|
||||
{ name: 'Screenshots' },
|
||||
{ name: 'SentryMinidump' },
|
||||
{ name: 'ElectronContext' },
|
||||
{ name: 'LocalVariables' },
|
||||
{ name: 'ElectronBreadcrumbs' },
|
||||
{ name: 'ScopeToMain' },
|
||||
]).map((integration) => integration.name)
|
||||
).toEqual(['MainProcessSession', 'OnUncaughtException', 'ScopeToMain']);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* Shared Sentry configuration constants.
|
||||
*
|
||||
* Used by both main and renderer process init modules.
|
||||
* Does NOT resolve DSN — each process does that with its own env access
|
||||
* Does NOT resolve DSN - each process does that with its own env access
|
||||
* (main: process.env, renderer: import.meta.env).
|
||||
*/
|
||||
|
||||
|
|
@ -24,3 +24,94 @@ export const TRACES_SAMPLE_RATE = process.env.NODE_ENV === 'production' ? 0.1 :
|
|||
export function isValidDsn(dsn: string | undefined): dsn is string {
|
||||
return typeof dsn === 'string' && dsn.length > 0 && dsn.startsWith('https://');
|
||||
}
|
||||
|
||||
const REDACTED = '[redacted]';
|
||||
const MAX_REDACTION_DEPTH = 8;
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/(token|secret|authorization|cookie|email|account|clientid|project|repo|path|cwd|teamname|sessionid|taskid|username|user_name)/i;
|
||||
|
||||
const SENSITIVE_STRING_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, REDACTED],
|
||||
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, REDACTED],
|
||||
[/\b(?:sk|pk|rk|ghp|gho|github_pat|xoxb|xoxp|ya29)[A-Za-z0-9_\-]{12,}\b/g, REDACTED],
|
||||
[/\/Users\/[^/\s"'`]+(?:\/[^\s"'`]+)*/g, '/Users/[redacted]/[redacted-path]'],
|
||||
[/\/home\/[^/\s"'`]+(?:\/[^\s"'`]+)*/g, '/home/[redacted]/[redacted-path]'],
|
||||
[/([A-Za-z]:\\Users\\)[^\\\s"'`]+(?:\\[^\\\s"'`]+)*/g, '$1[redacted]\\[redacted-path]'],
|
||||
];
|
||||
|
||||
const UNSAFE_SENTRY_INTEGRATION_NAMES = new Set([
|
||||
'AdditionalContext',
|
||||
'Breadcrumbs',
|
||||
'BrowserSession',
|
||||
'ChildProcess',
|
||||
'Console',
|
||||
'ContextLines',
|
||||
'CultureContext',
|
||||
'ElectronBreadcrumbs',
|
||||
'ElectronContext',
|
||||
'ElectronNet',
|
||||
'EventLoopBlockRenderer',
|
||||
'GpuContext',
|
||||
'HttpContext',
|
||||
'LocalVariables',
|
||||
'NativeNodeFetch',
|
||||
'NodeContext',
|
||||
'NodeFetch',
|
||||
'PreloadInjection',
|
||||
'RendererEventLoopBlock',
|
||||
'RendererProfiling',
|
||||
'Screenshots',
|
||||
'SentryMinidump',
|
||||
'StartupTracing',
|
||||
]);
|
||||
|
||||
interface SentryIntegrationLike {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function filterSafeSentryIntegrations<TIntegration extends SentryIntegrationLike>(
|
||||
integrations: TIntegration[]
|
||||
): TIntegration[] {
|
||||
return integrations.filter(
|
||||
(integration) => !integration.name || !UNSAFE_SENTRY_INTEGRATION_NAMES.has(integration.name)
|
||||
);
|
||||
}
|
||||
|
||||
function redactSentryString(value: string): string {
|
||||
return SENSITIVE_STRING_PATTERNS.reduce(
|
||||
(current, [pattern, replacement]) => current.replace(pattern, replacement),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
function redactSentryValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return redactSentryString(value);
|
||||
}
|
||||
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (depth >= MAX_REDACTION_DEPTH || seen.has(value)) {
|
||||
return REDACTED;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactSentryValue(entry, depth + 1, seen));
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = SENSITIVE_KEY_PATTERN.test(key)
|
||||
? REDACTED
|
||||
: redactSentryValue(entry, depth + 1, seen);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function redactSentryEvent(event: unknown): unknown {
|
||||
return redactSentryValue(event, 0, new WeakSet<object>());
|
||||
}
|
||||
|
|
|
|||
175
test/main/ipc/cliInstaller.test.ts
Normal file
175
test/main/ipc/cliInstaller.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
initializeCliInstallerHandlers,
|
||||
registerCliInstallerHandlers,
|
||||
} from '@main/ipc/cliInstaller';
|
||||
import {
|
||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||
CLI_INSTALLER_GET_STATUS,
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
import type { CliInstallerService } from '@main/services';
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus, IpcResult } from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown;
|
||||
|
||||
function createMockIpcMain(): IpcMain & {
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
|
||||
} {
|
||||
const handlers = new Map<string, IpcHandler>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: IpcHandler) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
removeHandler: vi.fn((channel: string) => {
|
||||
handlers.delete(channel);
|
||||
}),
|
||||
invoke: async (channel: string, ...args: unknown[]) => {
|
||||
const handler = handlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler for ${channel}`);
|
||||
}
|
||||
return await Promise.resolve(handler({} as IpcMainInvokeEvent, ...args));
|
||||
},
|
||||
};
|
||||
return ipcMain as unknown as IpcMain & {
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function provider(overrides: Partial<CliProviderStatus> & { providerId: CliProviderId }): CliProviderStatus {
|
||||
const { providerId, ...rest } = overrides;
|
||||
return {
|
||||
providerId,
|
||||
displayName: providerId,
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
modelCatalogRefreshState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: providerId !== 'opencode',
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
connection: null,
|
||||
modelCatalog: null,
|
||||
runtimeCapabilities: null,
|
||||
subscriptionRateLimits: null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function status(providers: CliProviderStatus[]): CliInstallationStatus {
|
||||
return {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
installed: true,
|
||||
installedVersion: '0.0.3',
|
||||
binaryPath: '/mock/agent_teams_orchestrator',
|
||||
launchError: null,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: false,
|
||||
authMethod: null,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
describe('cliInstaller IPC handlers', () => {
|
||||
let ipcMain: ReturnType<typeof createMockIpcMain>;
|
||||
let service: {
|
||||
getLatestStatusSnapshot: ReturnType<typeof vi.fn>;
|
||||
getStatus: ReturnType<typeof vi.fn>;
|
||||
getProviderStatus: ReturnType<typeof vi.fn>;
|
||||
verifyProviderModels: ReturnType<typeof vi.fn>;
|
||||
install: ReturnType<typeof vi.fn>;
|
||||
invalidateStatusCache: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMain = createMockIpcMain();
|
||||
service = {
|
||||
getLatestStatusSnapshot: vi.fn(() => null),
|
||||
getStatus: vi.fn(),
|
||||
getProviderStatus: vi.fn(),
|
||||
verifyProviderModels: vi.fn(),
|
||||
install: vi.fn(),
|
||||
invalidateStatusCache: vi.fn(),
|
||||
};
|
||||
initializeCliInstallerHandlers(service as unknown as CliInstallerService);
|
||||
registerCliInstallerHandlers(ipcMain);
|
||||
});
|
||||
|
||||
it('does not let explicit hidden Gemini refresh poison cached frontend auth status', async () => {
|
||||
service.getStatus.mockResolvedValue(
|
||||
status([
|
||||
provider({ providerId: 'anthropic' }),
|
||||
provider({ providerId: 'codex' }),
|
||||
provider({ providerId: 'opencode', canLoginFromUi: false }),
|
||||
])
|
||||
);
|
||||
service.getProviderStatus.mockResolvedValue(
|
||||
provider({
|
||||
providerId: 'gemini',
|
||||
authenticated: true,
|
||||
authMethod: 'gemini_api_key',
|
||||
models: ['gemini-2.5-pro'],
|
||||
})
|
||||
);
|
||||
|
||||
const initial = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
|
||||
expect(initial.success).toBe(true);
|
||||
expect(initial.data?.providers.map((entry) => entry.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'opencode',
|
||||
]);
|
||||
|
||||
const gemini = (await ipcMain.invoke(
|
||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||
'gemini'
|
||||
)) as IpcResult<CliProviderStatus | null>;
|
||||
expect(gemini.success).toBe(true);
|
||||
expect(gemini.data?.authenticated).toBe(true);
|
||||
|
||||
const cached = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
|
||||
expect(service.getStatus).toHaveBeenCalledTimes(1);
|
||||
expect(cached.success).toBe(true);
|
||||
expect(cached.data?.providers.map((entry) => entry.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'opencode',
|
||||
]);
|
||||
expect(cached.data?.authLoggedIn).toBe(false);
|
||||
expect(cached.data?.authMethod).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('main Sentry telemetry gate', () => {
|
||||
|
|
@ -18,20 +22,99 @@ describe('main Sentry telemetry gate', () => {
|
|||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not initialize Sentry when persisted telemetry config is disabled', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-sentry-config-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, 'agent-teams-config.json'),
|
||||
JSON.stringify({ general: { telemetryEnabled: false } }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const { setClaudeBasePathOverride } = await import('@main/utils/pathDecoder');
|
||||
setClaudeBasePathOverride(tempRoot);
|
||||
|
||||
const sentrySdk = await import('@sentry/electron/main');
|
||||
const init = vi.mocked(sentrySdk.init);
|
||||
init.mockClear();
|
||||
|
||||
const sentry = await import('@main/sentry');
|
||||
|
||||
expect(sentry.readPersistedTelemetryEnabled(tempRoot)).toBe(false);
|
||||
expect(init).not.toHaveBeenCalled();
|
||||
expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull();
|
||||
|
||||
setClaudeBasePathOverride(null);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('clears user scope and drops events when telemetry is disabled', async () => {
|
||||
const sentry = await import('@main/sentry');
|
||||
const sentryApi = {
|
||||
setUser: vi.fn(),
|
||||
setTags: vi.fn(),
|
||||
close: vi.fn(() => Promise.resolve(true)),
|
||||
};
|
||||
sentry.setMainSentryApiForTesting(sentryApi);
|
||||
|
||||
sentry.syncTelemetryFlag(false);
|
||||
|
||||
expect(sentryApi.setUser).toHaveBeenCalledWith(null);
|
||||
expect(sentryApi.close).toHaveBeenCalled();
|
||||
expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns only hashed anonymous Sentry context when telemetry is enabled', async () => {
|
||||
const sentry = await import('@main/sentry');
|
||||
|
||||
sentry.syncTelemetryFlag(true);
|
||||
const context = await sentry.getCurrentSentryTelemetryContext();
|
||||
|
||||
expect(context?.userId).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(Object.keys(context?.tags ?? {}).sort((a, b) => a.localeCompare(b))).toEqual([
|
||||
'app_version',
|
||||
'arch',
|
||||
'identity_source',
|
||||
'platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not attach high-cardinality breadcrumb data', async () => {
|
||||
const sentry = await import('@main/sentry');
|
||||
const sentryApi = {
|
||||
addBreadcrumb: vi.fn(),
|
||||
};
|
||||
sentry.setMainSentryApiForTesting(sentryApi);
|
||||
|
||||
sentry.addMainBreadcrumb('team', 'launch', { teamName: 'private-team-name' });
|
||||
|
||||
expect(sentryApi.addBreadcrumb).toHaveBeenCalledWith({
|
||||
category: 'team',
|
||||
message: 'launch',
|
||||
level: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts sensitive fields before allowing telemetry events', async () => {
|
||||
const sentry = await import('@main/sentry');
|
||||
|
||||
sentry.syncTelemetryFlag(true);
|
||||
const filtered = sentry.filterSentryEventForTelemetry({
|
||||
message: 'Failed for user dev@example.com in /Users/alice/private-repo',
|
||||
extra: {
|
||||
projectPath: '/Users/alice/private-repo',
|
||||
token: 'sk-testsecretsecretsecret',
|
||||
accountUuid: 'd9b2d63a-582c-4d69-8a01-90e8199f532d',
|
||||
},
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(filtered);
|
||||
expect(serialized).not.toContain('dev@example.com');
|
||||
expect(serialized).not.toContain('alice');
|
||||
expect(serialized).not.toContain('private-repo');
|
||||
expect(serialized).not.toContain('sk-testsecretsecretsecret');
|
||||
expect(serialized).not.toContain('d9b2d63a-582c-4d69-8a01-90e8199f532d');
|
||||
});
|
||||
|
||||
it('only exposes safe low-cardinality telemetry tags', async () => {
|
||||
const { getSafeSentryTelemetryTags } = await import('@main/sentry');
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ describe('CliInstallerService', () => {
|
|||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('includes OpenCode in unavailable multimodel bootstrap status', async () => {
|
||||
it('includes frontend-visible providers in unavailable multimodel bootstrap status', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||
|
|
@ -147,7 +147,6 @@ describe('CliInstallerService', () => {
|
|||
expect(status.providers.map((provider) => provider.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode',
|
||||
]);
|
||||
expect(openCodeStatus).toMatchObject({
|
||||
|
|
@ -158,6 +157,104 @@ describe('CliInstallerService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not expose hidden Gemini in frontend multimodel authentication snapshots', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
});
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||
vi.mocked(execCli).mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' });
|
||||
|
||||
const providers = [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
|
||||
backend: null,
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
|
||||
backend: null,
|
||||
},
|
||||
{
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'gemini_api_key',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
models: ['gemini-2.5-pro'],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
|
||||
backend: { kind: 'api', label: 'Gemini API' },
|
||||
},
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: { teamLaunch: true, oneShot: false, extensions: undefined as never },
|
||||
backend: null,
|
||||
},
|
||||
];
|
||||
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation(
|
||||
async (_binaryPath, onUpdate) => {
|
||||
onUpdate?.(providers as never);
|
||||
return providers as never;
|
||||
}
|
||||
);
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.providers.map((provider) => provider.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'opencode',
|
||||
]);
|
||||
expect(status.authLoggedIn).toBe(false);
|
||||
expect(status.authMethod).toBeNull();
|
||||
expect(
|
||||
service
|
||||
.getLatestStatusSnapshot()
|
||||
?.providers.some((provider) => provider.providerId === 'gemini')
|
||||
).toBe(false);
|
||||
expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ const execCliMock = vi.fn();
|
|||
const buildProviderAwareCliEnvMock = vi.fn();
|
||||
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
|
||||
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>();
|
||||
const enrichProviderStatusMock = vi.fn((provider) => Promise.resolve(provider));
|
||||
const enrichProviderStatusMock = vi.fn(
|
||||
(provider, _options?: { hydrateModelCatalog?: boolean }) => Promise.resolve(provider)
|
||||
);
|
||||
const enrichProviderStatusesMock = vi.fn((providers) => Promise.resolve(providers));
|
||||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
|
|
@ -22,6 +24,7 @@ vi.mock('@main/utils/childProcess', () => ({
|
|||
|
||||
vi.mock('@main/utils/shellEnv', () => ({
|
||||
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
|
||||
resolveInteractiveShellEnvBestEffort: () => resolveInteractiveShellEnvMock(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
|
|
@ -84,11 +87,18 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('parses object-based model lists and exposes Gemini runtime status', async () => {
|
||||
it('keeps Gemini out of frontend aggregate fallback while explicit Gemini status still works', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args, options) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
const env = options?.env ?? {};
|
||||
|
||||
if (
|
||||
normalizedArgs.startsWith('runtime status --json --provider ') &&
|
||||
normalizedArgs.endsWith(' --summary')
|
||||
) {
|
||||
return Promise.reject(new Error('unknown option --summary'));
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'auth status --json --provider all') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
|
|
@ -183,7 +193,12 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
|
||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
|
||||
|
||||
expect(providers).toHaveLength(4);
|
||||
expect(providers).toHaveLength(3);
|
||||
expect(providers.map((provider) => provider.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'opencode',
|
||||
]);
|
||||
expect(providers[0]).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
authenticated: true,
|
||||
|
|
@ -205,6 +220,20 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
},
|
||||
});
|
||||
expect(providers[2]).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
models: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
});
|
||||
|
||||
const gemini = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'gemini');
|
||||
expect(gemini).toMatchObject({
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
supported: true,
|
||||
|
|
@ -219,21 +248,102 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
projectId: 'demo-project',
|
||||
},
|
||||
});
|
||||
expect(providers[3]).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
models: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('loads all providers with parallel provider-scoped runtime status probes', async () => {
|
||||
it('falls back to provider-scoped full runtime status without probing Gemini', async () => {
|
||||
const providerPayloads = {
|
||||
anthropic: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
models: ['claude-sonnet-4-5'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
codex: {
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
verificationState: 'unknown',
|
||||
canLoginFromUi: true,
|
||||
models: ['gpt-5-codex'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
opencode: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
models: ['openai/gpt-5.4-mini'],
|
||||
capabilities: { teamLaunch: true, oneShot: false },
|
||||
},
|
||||
} as const;
|
||||
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
|
||||
const providerId =
|
||||
providerArgIndex >= 0 && Array.isArray(args)
|
||||
? (args[providerArgIndex + 1] as keyof typeof providerPayloads)
|
||||
: null;
|
||||
|
||||
if (
|
||||
normalizedArgs.startsWith('runtime status --json --provider ') &&
|
||||
normalizedArgs.endsWith(' --summary')
|
||||
) {
|
||||
return Promise.reject(new Error('unknown option --summary'));
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedArgs.startsWith('runtime status --json --provider ') &&
|
||||
providerId &&
|
||||
providerPayloads[providerId]
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
[providerId]: providerPayloads[providerId],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
|
||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
|
||||
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
|
||||
|
||||
expect(providers.map((provider) => provider.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'opencode',
|
||||
]);
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
'runtime status --json --provider anthropic --summary',
|
||||
'runtime status --json --provider codex --summary',
|
||||
'runtime status --json --provider opencode --summary',
|
||||
'runtime status --json --provider anthropic',
|
||||
'runtime status --json --provider codex',
|
||||
'runtime status --json --provider opencode',
|
||||
])
|
||||
);
|
||||
expect(calls).not.toContain('runtime status --json --provider gemini');
|
||||
expect(calls).not.toContain('runtime status --json');
|
||||
expect(calls).not.toContain('auth status --json --provider all');
|
||||
expect(calls).not.toContain('model list --json --provider all');
|
||||
});
|
||||
|
||||
it('loads frontend providers with parallel provider-scoped runtime status probes', async () => {
|
||||
const providerPayloads = {
|
||||
anthropic: {
|
||||
supported: true,
|
||||
|
|
@ -311,24 +421,31 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
|
||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate);
|
||||
|
||||
expect(execCliMock).toHaveBeenCalledTimes(4);
|
||||
expect(execCliMock).toHaveBeenCalledTimes(3);
|
||||
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual(
|
||||
expect.arrayContaining([
|
||||
'runtime status --json --provider anthropic',
|
||||
'runtime status --json --provider codex',
|
||||
'runtime status --json --provider gemini',
|
||||
'runtime status --json --provider opencode',
|
||||
'runtime status --json --provider anthropic --summary',
|
||||
'runtime status --json --provider codex --summary',
|
||||
'runtime status --json --provider opencode --summary',
|
||||
])
|
||||
);
|
||||
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).not.toContain(
|
||||
'runtime status --json --provider gemini --summary'
|
||||
);
|
||||
expect(
|
||||
execCliMock.mock.calls
|
||||
.filter((call) => call[1].join(' ').startsWith('runtime status --json --provider '))
|
||||
.map((call) => call[2]?.maxBuffer)
|
||||
).toEqual([8 * 1024 * 1024, 8 * 1024 * 1024, 8 * 1024 * 1024, 8 * 1024 * 1024]);
|
||||
).toEqual([8 * 1024 * 1024, 8 * 1024 * 1024, 8 * 1024 * 1024]);
|
||||
expect(enrichProviderStatusMock).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
enrichProviderStatusMock.mock.calls.every(
|
||||
(call) => call[1]?.hydrateModelCatalog === false
|
||||
)
|
||||
).toBe(true);
|
||||
expect(providers.map((provider) => provider.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode',
|
||||
]);
|
||||
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
|
||||
|
|
@ -340,6 +457,717 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual(providers);
|
||||
});
|
||||
|
||||
it('hydrates model catalogs without overwriting live summary auth state', async () => {
|
||||
const summaryPayloads = {
|
||||
anthropic: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
models: ['sonnet'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
codex: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: null,
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||
},
|
||||
gemini: {
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
verificationState: 'unknown',
|
||||
canLoginFromUi: true,
|
||||
models: ['gemini-2.5-pro'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
opencode: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
models: ['opencode/big-pickle'],
|
||||
capabilities: { teamLaunch: true, oneShot: false },
|
||||
},
|
||||
} as const;
|
||||
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
|
||||
const providerId =
|
||||
providerArgIndex >= 0 && Array.isArray(args)
|
||||
? (args[providerArgIndex + 1] as keyof typeof summaryPayloads)
|
||||
: null;
|
||||
|
||||
if (
|
||||
normalizedArgs === 'runtime status --json --provider codex' &&
|
||||
providerId === 'codex'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
codex: {
|
||||
...summaryPayloads.codex,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'stale full status should not win',
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'codex',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-17T00:00:00.000Z',
|
||||
staleAt: '2026-05-17T00:10:00.000Z',
|
||||
defaultModelId: 'gpt-5.4',
|
||||
defaultLaunchModel: 'gpt-5.4',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
launchModel: 'gpt-5.4',
|
||||
displayName: 'GPT-5.4',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
|
||||
defaultReasoningEffort: 'medium',
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'skipped',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedArgs.startsWith('runtime status --json --provider ') &&
|
||||
normalizedArgs.endsWith(' --summary') &&
|
||||
providerId &&
|
||||
summaryPayloads[providerId]
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
[providerId]: summaryPayloads[providerId],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
type ProviderStatuses = Awaited<ReturnType<typeof service.getProviderStatuses>>;
|
||||
let resolveHydrated!: (providers: ProviderStatuses) => void;
|
||||
const hydrated = new Promise<ProviderStatuses>((resolve) => {
|
||||
resolveHydrated = resolve;
|
||||
});
|
||||
const onUpdate = vi.fn((providers: ProviderStatuses) => {
|
||||
if (providers.find((provider) => provider.providerId === 'codex')?.modelCatalog) {
|
||||
resolveHydrated(providers);
|
||||
}
|
||||
});
|
||||
|
||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate);
|
||||
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
modelCatalogRefreshState: 'loading',
|
||||
});
|
||||
|
||||
const hydratedProviders = await hydrated;
|
||||
const hydratedCodex = hydratedProviders.find((provider) => provider.providerId === 'codex');
|
||||
expect(hydratedCodex).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
statusMessage: null,
|
||||
modelCatalogRefreshState: 'ready',
|
||||
});
|
||||
expect(hydratedCodex?.modelCatalog?.models.map((model) => model.id)).toEqual(['gpt-5.4']);
|
||||
});
|
||||
|
||||
it('hydrates a single provider catalog after summary refresh', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
codex: {
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: null,
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider codex') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
codex: {
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'unknown',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: 'full status should not overwrite live summary',
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'codex',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-17T00:00:00.000Z',
|
||||
staleAt: '2026-05-17T00:10:00.000Z',
|
||||
defaultModelId: 'gpt-5.4',
|
||||
defaultLaunchModel: 'gpt-5.4',
|
||||
models: [],
|
||||
diagnostics: {
|
||||
configReadState: 'skipped',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
const onCatalogUpdate = vi.fn();
|
||||
|
||||
const provider = await service.getProviderStatus(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
'codex',
|
||||
onCatalogUpdate
|
||||
);
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
modelCatalogRefreshState: 'loading',
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onCatalogUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onCatalogUpdate.mock.calls[0]?.[0]).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
statusMessage: null,
|
||||
modelCatalogRefreshState: 'ready',
|
||||
modelCatalog: {
|
||||
defaultModelId: 'gpt-5.4',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates Anthropic subscription rate limits after the live summary status', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider anthropic --summary') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
anthropic: {
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
statusMessage: null,
|
||||
models: ['sonnet'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: { dynamic: true, source: 'anthropic-models-api' },
|
||||
},
|
||||
subscriptionRateLimits: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider anthropic') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
anthropic: {
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'unknown',
|
||||
canLoginFromUi: true,
|
||||
statusMessage: 'full status should not overwrite live summary',
|
||||
models: ['sonnet'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: { dynamic: true, source: 'anthropic-models-api' },
|
||||
},
|
||||
subscriptionRateLimits: {
|
||||
primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_800 },
|
||||
secondary: null,
|
||||
},
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-models-api',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-17T00:00:00.000Z',
|
||||
staleAt: '2026-05-17T00:10:00.000Z',
|
||||
defaultModelId: 'sonnet',
|
||||
defaultLaunchModel: 'sonnet',
|
||||
models: [],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
const onCatalogUpdate = vi.fn();
|
||||
|
||||
const provider = await service.getProviderStatus(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
'anthropic',
|
||||
onCatalogUpdate
|
||||
);
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
subscriptionRateLimits: null,
|
||||
modelCatalogRefreshState: 'loading',
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onCatalogUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onCatalogUpdate.mock.calls[0]?.[0]).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
statusMessage: null,
|
||||
subscriptionRateLimits: {
|
||||
primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_800 },
|
||||
secondary: null,
|
||||
},
|
||||
modelCatalogRefreshState: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not cancel one provider catalog hydration when another provider refresh starts', async () => {
|
||||
let resolveCodexHydration!: (value: {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}) => void;
|
||||
const codexHydration = new Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}>((resolve) => {
|
||||
resolveCodexHydration = resolve;
|
||||
});
|
||||
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
codex: {
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: null,
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider anthropic --summary') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
anthropic: {
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
canLoginFromUi: true,
|
||||
statusMessage: 'Not connected',
|
||||
models: ['sonnet'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider codex') {
|
||||
return codexHydration;
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
const onCodexCatalogUpdate = vi.fn();
|
||||
|
||||
const codex = await service.getProviderStatus(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
'codex',
|
||||
onCodexCatalogUpdate
|
||||
);
|
||||
expect(codex.modelCatalogRefreshState).toBe('loading');
|
||||
|
||||
const anthropic = await service.getProviderStatus(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
'anthropic'
|
||||
);
|
||||
expect(anthropic.statusMessage).toBe('Not connected');
|
||||
|
||||
resolveCodexHydration({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
codex: {
|
||||
...codex,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'full status should not overwrite live summary',
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'codex',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-17T00:00:00.000Z',
|
||||
staleAt: '2026-05-17T00:10:00.000Z',
|
||||
defaultModelId: 'gpt-5.4',
|
||||
defaultLaunchModel: 'gpt-5.4',
|
||||
models: [],
|
||||
diagnostics: {
|
||||
configReadState: 'skipped',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onCodexCatalogUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onCodexCatalogUpdate.mock.calls[0]?.[0]).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
statusMessage: null,
|
||||
modelCatalogRefreshState: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale catalog hydration from an older provider status refresh', async () => {
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { HOME: '/Users/tester' },
|
||||
connectionIssues: {},
|
||||
});
|
||||
|
||||
const codexSummaryConnected = {
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: null,
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||
};
|
||||
const codexSummaryDisconnected = {
|
||||
...codexSummaryConnected,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'Not connected',
|
||||
};
|
||||
const staticSummaryPayloads = {
|
||||
anthropic: {
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
verificationState: 'unknown',
|
||||
canLoginFromUi: true,
|
||||
models: ['sonnet'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
gemini: {
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
verificationState: 'unknown',
|
||||
canLoginFromUi: true,
|
||||
models: ['gemini-2.5-pro'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
opencode: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
models: ['opencode/big-pickle'],
|
||||
capabilities: { teamLaunch: true, oneShot: false },
|
||||
},
|
||||
} as const;
|
||||
|
||||
let codexSummaryCalls = 0;
|
||||
let codexFullCalls = 0;
|
||||
let firstHydrationStarted = false;
|
||||
let resolveFirstHydration!: (value: {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}) => void;
|
||||
const firstHydration = new Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}>((resolve) => {
|
||||
resolveFirstHydration = resolve;
|
||||
});
|
||||
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
|
||||
const providerId =
|
||||
providerArgIndex >= 0 && Array.isArray(args)
|
||||
? (args[providerArgIndex + 1] as keyof typeof staticSummaryPayloads | 'codex')
|
||||
: null;
|
||||
|
||||
if (
|
||||
normalizedArgs.startsWith('runtime status --json --provider ') &&
|
||||
normalizedArgs.endsWith(' --summary') &&
|
||||
providerId
|
||||
) {
|
||||
const payload =
|
||||
providerId === 'codex'
|
||||
? ++codexSummaryCalls === 1
|
||||
? codexSummaryConnected
|
||||
: codexSummaryDisconnected
|
||||
: staticSummaryPayloads[providerId];
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
[providerId]: payload,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider codex') {
|
||||
codexFullCalls += 1;
|
||||
if (codexFullCalls === 1) {
|
||||
firstHydrationStarted = true;
|
||||
return firstHydration;
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
codex: {
|
||||
...codexSummaryDisconnected,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
statusMessage: 'fresh full status should not overwrite live summary',
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'codex',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-17T00:01:00.000Z',
|
||||
staleAt: '2026-05-17T00:11:00.000Z',
|
||||
defaultModelId: 'fresh-model',
|
||||
defaultLaunchModel: 'fresh-model',
|
||||
models: [],
|
||||
diagnostics: {
|
||||
configReadState: 'skipped',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
type ProviderStatuses = Awaited<ReturnType<typeof service.getProviderStatuses>>;
|
||||
const firstUpdates = vi.fn((_: ProviderStatuses) => undefined);
|
||||
const secondUpdates = vi.fn((_: ProviderStatuses) => undefined);
|
||||
|
||||
const firstProviders = await service.getProviderStatuses(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
firstUpdates
|
||||
);
|
||||
expect(firstProviders.find((provider) => provider.providerId === 'codex')).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 10 && !firstHydrationStarted; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
expect(firstHydrationStarted).toBe(true);
|
||||
|
||||
const secondProviders = await service.getProviderStatuses(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
secondUpdates
|
||||
);
|
||||
expect(secondProviders.find((provider) => provider.providerId === 'codex')).toMatchObject({
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'Not connected',
|
||||
});
|
||||
|
||||
resolveFirstHydration({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
codex: {
|
||||
...codexSummaryConnected,
|
||||
statusMessage: 'old catalog hydration',
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'codex',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-17T00:00:00.000Z',
|
||||
staleAt: '2026-05-17T00:10:00.000Z',
|
||||
defaultModelId: 'old-model',
|
||||
defaultLaunchModel: 'old-model',
|
||||
models: [],
|
||||
diagnostics: {
|
||||
configReadState: 'skipped',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const hasOldCatalogUpdate = [...firstUpdates.mock.calls, ...secondUpdates.mock.calls].some(
|
||||
([providers]) =>
|
||||
providers
|
||||
.find((provider) => provider.providerId === 'codex')
|
||||
?.modelCatalog?.defaultModelId === 'old-model'
|
||||
);
|
||||
expect(hasOldCatalogUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { HOME: '/Users/tester' },
|
||||
|
|
@ -836,7 +1664,10 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider opencode') {
|
||||
if (
|
||||
normalizedArgs === 'runtime status --json --provider opencode' ||
|
||||
normalizedArgs === 'runtime status --json --provider opencode --summary'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
|
|
|
|||
|
|
@ -1577,6 +1577,72 @@ describe('ProviderConnectionService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('skips Codex catalog hydration when summary enrichment disables catalog loading', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
const directCatalog = vi.fn().mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
providerId: 'codex',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-04-28T00:00:00.000Z',
|
||||
staleAt: '2026-04-28T00:10:00.000Z',
|
||||
defaultModelId: 'gpt-5.4',
|
||||
defaultLaunchModel: 'gpt-5.4',
|
||||
models: [],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
});
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => createConfig('auto'),
|
||||
} as never
|
||||
);
|
||||
service.setCodexModelCatalogFeature({ getCatalog: directCatalog } as never);
|
||||
|
||||
const enriched = await service.enrichProviderStatus(
|
||||
{
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
models: ['gpt-5.4'],
|
||||
modelCatalog: null,
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: { dynamic: true, source: 'app-server' },
|
||||
},
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
plugins: { status: 'unsupported', ownership: 'shared' },
|
||||
mcp: { status: 'supported', ownership: 'shared' },
|
||||
skills: { status: 'supported', ownership: 'shared' },
|
||||
apiKeys: { status: 'supported', ownership: 'shared' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ hydrateModelCatalog: false }
|
||||
);
|
||||
|
||||
expect(directCatalog).not.toHaveBeenCalled();
|
||||
expect(enriched.models).toEqual(['gpt-5.4']);
|
||||
expect(enriched.modelCatalog).toBeNull();
|
||||
expect(enriched.runtimeCapabilities?.modelCatalog).toEqual({
|
||||
dynamic: true,
|
||||
source: 'app-server',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the stored Anthropic API key for team helper mode only in api_key auth mode', async () => {
|
||||
const lookupPreferred = vi.fn().mockResolvedValue({
|
||||
envVarName: 'ANTHROPIC_API_KEY',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,40 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
|
|||
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded');
|
||||
});
|
||||
|
||||
it('selects OpenCode free usage exhaustion before empty assistant fallback text', () => {
|
||||
const record = {
|
||||
diagnostics: [
|
||||
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.267Z',
|
||||
'empty_assistant_turn',
|
||||
],
|
||||
lastReason: 'empty_assistant_turn',
|
||||
responseState: 'empty_assistant_turn',
|
||||
status: 'failed_terminal',
|
||||
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
|
||||
|
||||
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Free usage exceeded');
|
||||
expect(
|
||||
isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores positive OpenCode delivery breadcrumbs before fallback text', () => {
|
||||
const record = {
|
||||
diagnostics: [
|
||||
'OpenCode app MCP is connected for message delivery.',
|
||||
'OpenCode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.',
|
||||
'prompt_delivered_no_assistant_message',
|
||||
],
|
||||
lastReason: 'prompt_delivered_no_assistant_message',
|
||||
responseState: 'prompt_delivered_no_assistant_message',
|
||||
status: 'failed_terminal',
|
||||
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
|
||||
|
||||
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
|
||||
'OpenCode accepted the prompt, but no assistant turn was recorded.'
|
||||
);
|
||||
});
|
||||
|
||||
it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => {
|
||||
const record = {
|
||||
diagnostics: [
|
||||
|
|
|
|||
|
|
@ -35,6 +35,20 @@ describe('RuntimeDiagnosticClassifier', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('classifies OpenCode free usage retry status as quota exhausted', () => {
|
||||
const selected = selectRuntimeDiagnosticClassification([
|
||||
'empty_assistant_turn',
|
||||
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.267Z',
|
||||
]);
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
reasonCode: 'quota_exhausted',
|
||||
normalizedMessage:
|
||||
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.267Z',
|
||||
actionRequired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects auth errors over bridge timeouts', () => {
|
||||
const selected = selectRuntimeDiagnosticClassification([
|
||||
'OpenCode bridge command timed out',
|
||||
|
|
|
|||
|
|
@ -363,6 +363,107 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
|||
expect(advisory?.message).not.toContain('Latest assistant message');
|
||||
});
|
||||
|
||||
it('keeps pending OpenCode free usage exhaustion visible while delivery is unresolved', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-17T21:44:45.000Z'));
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
||||
const teamName = 'forge-labs';
|
||||
const laneId = 'secondary:opencode:tom';
|
||||
const oldIso = '2026-05-17T21:44:34.000Z';
|
||||
const laneDir = path.join(
|
||||
tmpDir,
|
||||
'teams',
|
||||
teamName,
|
||||
'.opencode-runtime',
|
||||
'lanes',
|
||||
encodeURIComponent(laneId)
|
||||
);
|
||||
await fs.mkdir(laneDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updatedAt: oldIso,
|
||||
lanes: {
|
||||
[laneId]: { laneId, state: 'active', updatedAt: oldIso },
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
updatedAt: oldIso,
|
||||
data: [
|
||||
{
|
||||
id: 'opencode-prompt:free-usage-pending',
|
||||
teamName,
|
||||
memberName: 'tom',
|
||||
laneId,
|
||||
runId: 'run-1',
|
||||
runtimeSessionId: 'ses-1',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: oldIso,
|
||||
source: 'watcher',
|
||||
messageKind: null,
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: null,
|
||||
taskRefs: [],
|
||||
payloadHash: 'sha256:test',
|
||||
status: 'accepted',
|
||||
responseState: 'pending',
|
||||
attempts: 2,
|
||||
maxAttempts: 3,
|
||||
acceptanceUnknown: false,
|
||||
nextAttemptAt: '2026-05-17T21:44:37.000Z',
|
||||
lastAttemptAt: oldIso,
|
||||
lastObservedAt: oldIso,
|
||||
acceptedAt: '2026-05-17T21:40:21.000Z',
|
||||
respondedAt: null,
|
||||
failedAt: null,
|
||||
inboxReadCommittedAt: null,
|
||||
inboxReadCommitError: null,
|
||||
prePromptCursor: null,
|
||||
postPromptCursor: null,
|
||||
deliveredUserMessageId: 'msg-opencode-user',
|
||||
observedAssistantMessageId: 'msg-opencode-assistant',
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: [],
|
||||
observedVisibleMessageId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyInbox: null,
|
||||
visibleReplyCorrelation: null,
|
||||
lastReason: 'assistant_response_pending',
|
||||
diagnostics: [
|
||||
'OpenCode app MCP is connected for message delivery.',
|
||||
'OpenCode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.',
|
||||
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.502Z)',
|
||||
],
|
||||
createdAt: oldIso,
|
||||
updatedAt: oldIso,
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const service = new TeamMemberRuntimeAdvisoryService({
|
||||
findMemberLogs: vi.fn(async () => []),
|
||||
});
|
||||
const advisory = await service.getMemberAdvisory(teamName, 'tom');
|
||||
|
||||
expect(advisory).toMatchObject({
|
||||
kind: 'api_error',
|
||||
reasonCode: 'quota_exhausted',
|
||||
retryUntil: '2026-05-18T00:00:00.502Z',
|
||||
});
|
||||
expect(advisory?.retryDelayMs).toBeGreaterThan(0);
|
||||
expect(advisory?.message).toContain('Free usage exceeded');
|
||||
});
|
||||
|
||||
it('classifies terminal OpenCode protocol proof failures as warnings, not provider errors', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -994,7 +994,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('runs OpenCode model verification with bounded concurrency and preserves model order', async () => {
|
||||
it('serializes OpenCode model verification and preserves model order', async () => {
|
||||
const started: string[] = [];
|
||||
let activeCount = 0;
|
||||
let maxActiveCount = 0;
|
||||
|
|
@ -1052,11 +1052,16 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(started).toEqual(['opencode/minimax-m2.5-free']));
|
||||
expect(maxActiveCount).toBe(1);
|
||||
expect(releases.has('opencode/nemotron-3-super-free')).toBe(false);
|
||||
expect(releases.has('opencode/big-pickle')).toBe(false);
|
||||
|
||||
releases.get('opencode/minimax-m2.5-free')?.();
|
||||
await vi.waitFor(() =>
|
||||
expect(started).toEqual(['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'])
|
||||
);
|
||||
expect(maxActiveCount).toBe(2);
|
||||
expect(releases.has('opencode/big-pickle')).toBe(false);
|
||||
expect(maxActiveCount).toBe(1);
|
||||
|
||||
releases.get('opencode/nemotron-3-super-free')?.();
|
||||
await vi.waitFor(() =>
|
||||
|
|
@ -1066,10 +1071,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
'opencode/big-pickle',
|
||||
])
|
||||
);
|
||||
expect(maxActiveCount).toBe(2);
|
||||
expect(maxActiveCount).toBe(1);
|
||||
|
||||
releases.get('opencode/big-pickle')?.();
|
||||
releases.get('opencode/minimax-m2.5-free')?.();
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
|
|
@ -1079,7 +1083,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
'Selected model opencode/nemotron-3-super-free verified for launch.',
|
||||
]);
|
||||
expect(result.warnings).toEqual([
|
||||
'Selected model opencode/big-pickle could not be verified. provider busy',
|
||||
'Selected model opencode/big-pickle verification deferred. OpenCode session is busy; retry when idle.',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ interface StoreState {
|
|||
worktrees: { path: string }[];
|
||||
}[];
|
||||
teams: { teamName: string; displayName: string }[];
|
||||
provisioningRuns: Record<string, { state: string; runId: string; updatedAt: string }>;
|
||||
currentProvisioningRunIdByTeam: Record<string, string | null>;
|
||||
leadActivityByTeam: Record<string, 'active' | 'idle' | 'offline'>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
|
|
@ -83,12 +86,21 @@ vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
|
||||
SidebarTaskItem: ({ task, hideProjectName }: { task: GlobalTask; hideProjectName?: boolean }) =>
|
||||
SidebarTaskItem: ({
|
||||
task,
|
||||
hideProjectName,
|
||||
teamOffline,
|
||||
}: {
|
||||
task: GlobalTask;
|
||||
hideProjectName?: boolean;
|
||||
teamOffline?: boolean;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'sidebar-task-item',
|
||||
'data-hide-project-name': hideProjectName ? 'true' : 'false',
|
||||
'data-team-offline': teamOffline ? 'true' : 'false',
|
||||
},
|
||||
task.subject
|
||||
),
|
||||
|
|
@ -189,6 +201,9 @@ describe('GlobalTaskList project grouping', () => {
|
|||
storeState.viewMode = 'flat';
|
||||
storeState.repositoryGroups = [];
|
||||
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
|
||||
storeState.provisioningRuns = {};
|
||||
storeState.currentProvisioningRunIdByTeam = {};
|
||||
storeState.leadActivityByTeam = {};
|
||||
toggleCollapsedGroup.mockReset();
|
||||
taskLocalState.isPinned.mockClear();
|
||||
taskLocalState.isArchived.mockClear();
|
||||
|
|
@ -277,6 +292,30 @@ describe('GlobalTaskList project grouping', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('marks task cards as offline when the owning team has gone offline', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.globalTasks = [makeTask(1)];
|
||||
storeState.leadActivityByTeam = { 'alpha-team': 'offline' };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalTaskList));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(
|
||||
host.querySelector('[data-testid="sidebar-task-item"]')?.getAttribute('data-team-offline')
|
||||
).toBe('true');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));
|
||||
|
|
|
|||
|
|
@ -161,6 +161,26 @@ describe('SidebarTaskItem unread styling', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('pauses the in-progress status icon when the task team is offline', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(SidebarTaskItem, { task: makeTask(), teamOffline: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('svg')?.getAttribute('class')).not.toContain('animate-spin');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('can hide the project label when the parent already groups by project', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
|
|
|
|||
|
|
@ -349,6 +349,63 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows an OpenCode catalog loading skeleton instead of the transient big-pickle placeholder', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
authMethod: 'opencode_managed',
|
||||
backend: {
|
||||
kind: 'opencode-cli',
|
||||
label: 'OpenCode CLI',
|
||||
endpointLabel: 'opencode',
|
||||
},
|
||||
authenticated: true,
|
||||
supported: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
},
|
||||
models: ['opencode/big-pickle'],
|
||||
modelCatalog: null,
|
||||
modelCatalogRefreshState: 'loading',
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TeamModelSelector, {
|
||||
providerId: 'opencode',
|
||||
onProviderChange: () => undefined,
|
||||
value: '',
|
||||
onValueChange: () => undefined,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
host.querySelector('[data-testid="team-model-selector-opencode-loading-skeleton"]')
|
||||
).not.toBeNull();
|
||||
expect(host.textContent).toContain('Default');
|
||||
expect(host.textContent).toContain('Loading OpenCode models...');
|
||||
expect(host.textContent).not.toContain('big-pickle');
|
||||
expect(host.textContent).not.toContain('Recommended only');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('virtualizes large OpenCode model lists instead of rendering every model tile', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const models = Array.from(
|
||||
|
|
|
|||
|
|
@ -202,6 +202,42 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('summarizes OpenCode busy model checks as deferred notes', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProvisioningProviderStatusList, {
|
||||
checks: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'notes',
|
||||
backendSummary: 'OpenCode CLI',
|
||||
details: [
|
||||
'qwen/qwen3-235b-a22b-thinking-2507 - verification deferred - OpenCode session is busy; retry when idle.',
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain(
|
||||
'OpenCode (OpenCode CLI): Selected model checks - 1 verification deferred'
|
||||
);
|
||||
expect(host.textContent).not.toContain('model check failed');
|
||||
expect(host.textContent).not.toContain('Needs attention');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not count generic one-shot diagnostic timeouts as model timeouts', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -487,6 +487,61 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('treats OpenCode busy model verification as deferred notes', async () => {
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_cwd, _providerId, _providerIds, _selectedModels, _limitContext, modelVerificationMode) =>
|
||||
Promise.resolve(
|
||||
modelVerificationMode === 'compatibility'
|
||||
? {
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
details: [
|
||||
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
|
||||
],
|
||||
warnings: [],
|
||||
}
|
||||
: {
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
warnings: [
|
||||
'Selected model opencode/big-pickle verification deferred. OpenCode session is busy; retry when idle.',
|
||||
],
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/big-pickle'],
|
||||
prepareProvisioning,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('notes');
|
||||
expect(result.details).toEqual([
|
||||
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
|
||||
]);
|
||||
expect(result.warnings).toEqual([
|
||||
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
|
||||
]);
|
||||
expect(result.modelResultsById).toEqual({
|
||||
'opencode/big-pickle': {
|
||||
status: 'notes',
|
||||
line: 'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
|
||||
warningLine:
|
||||
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {
|
||||
const runtimeFailure =
|
||||
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?';
|
||||
|
|
|
|||
|
|
@ -100,4 +100,29 @@ describe('CurrentTaskIndicator', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('pauses the spinner when the activity timer is not running', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CurrentTaskIndicator, {
|
||||
task,
|
||||
borderColor: '#3b82f6',
|
||||
isTimerRunning: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('svg.animate-spin')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,12 +27,51 @@ vi.mock('@renderer/components/ui/badge', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipProvider: ({
|
||||
children,
|
||||
delayDuration,
|
||||
skipDelayDuration,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
delayDuration?: number;
|
||||
skipDelayDuration?: number;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'tooltip-provider',
|
||||
'data-delay-duration': delayDuration,
|
||||
'data-skip-delay-duration': skipDelayDuration,
|
||||
},
|
||||
children
|
||||
),
|
||||
Tooltip: ({
|
||||
children,
|
||||
delayDuration,
|
||||
open,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
delayDuration?: number;
|
||||
open?: boolean;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'tooltip-root',
|
||||
'data-delay-duration': delayDuration,
|
||||
'data-open': open,
|
||||
},
|
||||
children
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
TooltipContent: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => React.createElement('div', { className, 'data-testid': 'tooltip-content' }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useTheme', () => ({
|
||||
|
|
@ -240,6 +279,156 @@ describe('MemberCard starting-state visuals', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows timed OpenCode quota advisory with a relaunch action', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onRestartMember = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member: {
|
||||
...member,
|
||||
providerId: 'opencode',
|
||||
runtimeAdvisory: {
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-05-17T21:44:34.000Z',
|
||||
retryUntil: '2099-05-18T00:00:00.000Z',
|
||||
retryDelayMs: 8_000,
|
||||
reasonCode: 'quota_exhausted',
|
||||
message: 'Free usage exceeded, subscribe to Go https://opencode.ai/go',
|
||||
},
|
||||
},
|
||||
memberColor: 'blue',
|
||||
currentTask,
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'online',
|
||||
spawnLaunchState: 'confirmed_alive',
|
||||
spawnRuntimeAlive: true,
|
||||
onRestartMember,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode quota error · retry');
|
||||
const relaunchButton = host.querySelector('button[aria-label="Relaunch OpenCode"]');
|
||||
expect(relaunchButton).not.toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
(relaunchButton as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the OpenCode advisory relaunch action in awaiting-reply rows', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onRestartMember = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member: {
|
||||
...member,
|
||||
providerId: 'opencode',
|
||||
runtimeAdvisory: {
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-05-17T21:44:34.000Z',
|
||||
retryUntil: '2099-05-18T00:00:00.000Z',
|
||||
retryDelayMs: 8_000,
|
||||
reasonCode: 'quota_exhausted',
|
||||
message: 'Free usage exceeded, subscribe to Go https://opencode.ai/go',
|
||||
},
|
||||
},
|
||||
memberColor: 'blue',
|
||||
isAwaitingReply: true,
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'online',
|
||||
spawnLaunchState: 'confirmed_alive',
|
||||
spawnRuntimeAlive: true,
|
||||
onRestartMember,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode quota error · retry');
|
||||
const relaunchButton = host.querySelector('button[aria-label="Relaunch OpenCode"]');
|
||||
expect(relaunchButton).not.toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
(relaunchButton as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show the OpenCode advisory relaunch action for protocol-proof warnings', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onRestartMember = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member: {
|
||||
...member,
|
||||
providerId: 'opencode',
|
||||
runtimeAdvisory: {
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-05-17T21:44:34.000Z',
|
||||
reasonCode: 'protocol_proof_missing',
|
||||
message: 'non_visible_tool_without_task_progress',
|
||||
},
|
||||
},
|
||||
memberColor: 'blue',
|
||||
currentTask,
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'online',
|
||||
spawnLaunchState: 'confirmed_alive',
|
||||
spawnRuntimeAlive: true,
|
||||
onRestartMember,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode proof missing');
|
||||
expect(host.querySelector('button[aria-label="Relaunch OpenCode"]')).toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).toBeNull();
|
||||
expect(onRestartMember).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps runtime-pending launch status visible even when the teammate has an active task', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
@ -535,7 +724,7 @@ describe('MemberCard starting-state visuals', () => {
|
|||
restartable: false,
|
||||
providerId: 'opencode',
|
||||
pid: 333,
|
||||
pidSource: 'opencode_bridge',
|
||||
runtimeLoadScope: 'shared-host',
|
||||
rssBytes: 183.9 * 1024 * 1024,
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
|
|
@ -575,11 +764,23 @@ describe('MemberCard starting-state visuals', () => {
|
|||
pidSource: 'tmux_child',
|
||||
rssBytes: 238.3 * 1024 * 1024,
|
||||
cpuPercent: 14,
|
||||
primaryCpuPercent: 4,
|
||||
primaryRssBytes: 210 * 1024 * 1024,
|
||||
childCpuPercent: 10,
|
||||
childRssBytes: 28.3 * 1024 * 1024,
|
||||
processCount: 3,
|
||||
runtimeLoadScope: 'process-tree',
|
||||
resourceHistory: [
|
||||
{
|
||||
timestamp: '2026-04-24T12:00:00.000Z',
|
||||
rssBytes: 220 * 1024 * 1024,
|
||||
cpuPercent: 4,
|
||||
cpuPercent: 0,
|
||||
primaryCpuPercent: 0,
|
||||
primaryRssBytes: 210 * 1024 * 1024,
|
||||
childCpuPercent: 0,
|
||||
childRssBytes: 10 * 1024 * 1024,
|
||||
processCount: 2,
|
||||
runtimeLoadScope: 'process-tree',
|
||||
pidSource: 'tmux_child',
|
||||
pid: 222,
|
||||
},
|
||||
|
|
@ -587,12 +788,23 @@ describe('MemberCard starting-state visuals', () => {
|
|||
timestamp: '2026-04-24T12:00:05.000Z',
|
||||
rssBytes: 238.3 * 1024 * 1024,
|
||||
cpuPercent: 14,
|
||||
primaryCpuPercent: 4,
|
||||
primaryRssBytes: 210 * 1024 * 1024,
|
||||
childCpuPercent: 10,
|
||||
childRssBytes: 28.3 * 1024 * 1024,
|
||||
processCount: 3,
|
||||
runtimeLoadScope: 'process-tree',
|
||||
pidSource: 'tmux_child',
|
||||
pid: 222,
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-04-24T12:00:05.000Z',
|
||||
},
|
||||
runtimeTelemetryVisible: true,
|
||||
runtimeTelemetryScale: {
|
||||
cpuCapPercent: 100,
|
||||
memoryCapBytes: 512 * 1024 * 1024,
|
||||
},
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
})
|
||||
|
|
@ -603,6 +815,112 @@ describe('MemberCard starting-state visuals', () => {
|
|||
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
|
||||
expect(strip).not.toBeNull();
|
||||
expect(strip?.querySelector('path[fill="#22c55e"]')).not.toBeNull();
|
||||
const cpuPath = strip?.querySelector('path[stroke="#3b82f6"]');
|
||||
expect(cpuPath).not.toBeNull();
|
||||
expect(cpuPath?.getAttribute('d')).toContain('M0 16.10');
|
||||
expect(strip?.getAttribute('title')).toBeNull();
|
||||
expect(
|
||||
host.querySelector('[data-testid="tooltip-root"][data-delay-duration="0"]')
|
||||
).not.toBeNull();
|
||||
expect(host.querySelector('[data-testid="tooltip-root"]')?.getAttribute('data-open')).toBe(
|
||||
'false'
|
||||
);
|
||||
expect(host.textContent).toContain('Local runtime load');
|
||||
expect(host.textContent).toContain('Parent and child processes only.');
|
||||
expect(host.textContent).toContain('root PID 222');
|
||||
expect(host.textContent).toContain('3 processes');
|
||||
expect(host.textContent).toContain('CPU');
|
||||
expect(host.textContent).toContain('14%');
|
||||
expect(host.textContent).toContain('Memory');
|
||||
expect(host.textContent).toContain('238 MB');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed runtime telemetry history without crashing', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
runtimeSummary: 'gpt-5.4-mini · Codex · 238.3 MB',
|
||||
runtimeEntry: {
|
||||
memberName: 'alice',
|
||||
alive: true,
|
||||
restartable: true,
|
||||
providerId: 'codex',
|
||||
pid: 222,
|
||||
resourceHistory: 'not-an-array',
|
||||
updatedAt: '2026-04-24T12:00:05.000Z',
|
||||
} as any,
|
||||
runtimeTelemetryVisible: true,
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="member-runtime-telemetry-strip"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed runtime telemetry samples while rendering valid samples', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
runtimeSummary: 'gpt-5.4-mini · Codex · 238.3 MB',
|
||||
runtimeEntry: {
|
||||
memberName: 'alice',
|
||||
alive: true,
|
||||
restartable: true,
|
||||
providerId: 'codex',
|
||||
pid: 222,
|
||||
resourceHistory: [
|
||||
null,
|
||||
{
|
||||
timestamp: '2026-04-24T12:00:00.000Z',
|
||||
rssBytes: 220 * 1024 * 1024,
|
||||
cpuPercent: 0,
|
||||
},
|
||||
'bad-sample',
|
||||
{
|
||||
timestamp: '2026-04-24T12:00:05.000Z',
|
||||
rssBytes: 238 * 1024 * 1024,
|
||||
cpuPercent: 12,
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-04-24T12:00:05.000Z',
|
||||
} as any,
|
||||
runtimeTelemetryVisible: true,
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
|
||||
expect(strip).not.toBeNull();
|
||||
expect(strip?.querySelector('path[stroke="#3b82f6"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -478,8 +478,9 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('clears pending replies from durable user_sent history even if the local pending timestamp drifted later', () => {
|
||||
it('does not clear a fresh pending reply from older durable send history', () => {
|
||||
const pendingSentAtMs = Date.parse('2026-04-08T12:02:00.000Z');
|
||||
const pending = { forge: pendingSentAtMs };
|
||||
const messages: InboxMessage[] = [
|
||||
makeMessage({
|
||||
messageId: 'user-send',
|
||||
|
|
@ -499,7 +500,32 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
}),
|
||||
];
|
||||
|
||||
expect(reconcilePendingRepliesByMember({ forge: pendingSentAtMs }, messages)).toEqual({});
|
||||
expect(reconcilePendingRepliesByMember(pending, messages)).toBe(pending);
|
||||
});
|
||||
|
||||
it('keeps pending replies when a new local send has not materialized after an older lead answer', () => {
|
||||
const pendingSentAtMs = Date.parse('2026-04-08T12:02:00.000Z');
|
||||
const pending = { lead: pendingSentAtMs };
|
||||
const messages: InboxMessage[] = [
|
||||
makeMessage({
|
||||
messageId: 'older-user-send',
|
||||
from: 'user',
|
||||
to: 'lead',
|
||||
source: 'user_sent',
|
||||
timestamp: '2026-04-08T12:00:00.000Z',
|
||||
text: 'Предыдущий вопрос.',
|
||||
}),
|
||||
makeMessage({
|
||||
messageId: 'older-lead-thought-reply',
|
||||
from: 'lead',
|
||||
to: undefined,
|
||||
source: 'lead_session',
|
||||
timestamp: '2026-04-08T12:01:00.000Z',
|
||||
text: 'Предыдущий ответ.',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(reconcilePendingRepliesByMember(pending, messages)).toBe(pending);
|
||||
});
|
||||
|
||||
it('clears pending replies when the team lead answers through a visible lead thought', () => {
|
||||
|
|
|
|||
|
|
@ -61,4 +61,22 @@ describe('ScopeWarningBanner', () => {
|
|||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('uses work-interval wording for legacy interval-scoped task changes', async () => {
|
||||
const { host, cleanup } = await renderBanner({
|
||||
sourceKind: 'legacy',
|
||||
confidence: {
|
||||
tier: 2,
|
||||
label: 'medium',
|
||||
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
|
||||
},
|
||||
warnings: ['Task start boundary missing - scoped by persisted workIntervals timestamps.'],
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Scoped by persisted work interval');
|
||||
expect(host.textContent).toContain('Interval scoped');
|
||||
expect(host.textContent).not.toContain('End boundary estimated');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,13 +8,23 @@ import {
|
|||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
|
||||
describe('DialogContent FocusScope integration', () => {
|
||||
describe('Radix ref lifecycle integration', () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
|
|
@ -25,6 +35,7 @@ describe('DialogContent FocusScope integration', () => {
|
|||
root.unmount();
|
||||
});
|
||||
document.body.innerHTML = '';
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
|
@ -52,4 +63,31 @@ describe('DialogContent FocusScope integration', () => {
|
|||
|
||||
expect(document.body.textContent).toContain('Create team updated');
|
||||
});
|
||||
|
||||
it('keeps the Radix select and popper refs stable while an open select rerenders', () => {
|
||||
const renderSelect = (label: string): void => {
|
||||
root.render(
|
||||
<Select open value="codex">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">Claude {label}</SelectItem>
|
||||
<SelectItem value="codex">Codex {label}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
renderSelect('initial');
|
||||
});
|
||||
act(() => {
|
||||
renderSelect('updated');
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
expect(document.body.textContent).toContain('Codex updated');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ describe('cliInstallerSlice', () => {
|
|||
});
|
||||
|
||||
describe('mergeCliStatusPreservingHydratedProviders', () => {
|
||||
it('does not let model-only OpenCode fallback overwrite hydrated runtime status', () => {
|
||||
it('keeps cached OpenCode models without preserving stale runtime auth status', () => {
|
||||
const current = createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
|
|
@ -202,10 +202,11 @@ describe('cliInstallerSlice', () => {
|
|||
|
||||
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
||||
{
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
backend: null,
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -300,7 +301,47 @@ describe('cliInstallerSlice', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not let stale OpenCode missing-CLI status overwrite a refreshed model list', () => {
|
||||
it('drops stale hidden Gemini loading from multimodel auth checking', () => {
|
||||
const status = createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
models: ['claude-sonnet-4-5'],
|
||||
}),
|
||||
createMultimodelProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
models: ['gpt-5.4'],
|
||||
}),
|
||||
createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
models: ['opencode/big-pickle'],
|
||||
canLoginFromUi: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
reconcileMultimodelProviderLoading(status, {
|
||||
anthropic: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
})
|
||||
).toEqual({
|
||||
anthropic: false,
|
||||
codex: false,
|
||||
opencode: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps cached OpenCode models when a fresh runtime status reports missing CLI', () => {
|
||||
const current = createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
|
|
@ -336,8 +377,10 @@ describe('cliInstallerSlice', () => {
|
|||
|
||||
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
||||
{
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
statusMessage: 'OpenCode CLI not found',
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
}
|
||||
);
|
||||
|
|
@ -386,6 +429,59 @@ describe('cliInstallerSlice', () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('drops hydrated hidden Gemini when a fresh frontend status omits it', () => {
|
||||
const current = createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
models: ['claude-sonnet-4-5'],
|
||||
}),
|
||||
createMultimodelProvider({
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
authenticated: true,
|
||||
authMethod: 'gemini_api_key',
|
||||
models: ['gemini-2.5-pro'],
|
||||
}),
|
||||
]);
|
||||
const incoming = createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
models: ['claude-sonnet-4-5'],
|
||||
}),
|
||||
createMultimodelProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
models: ['gpt-5.4'],
|
||||
}),
|
||||
createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
models: ['opencode/big-pickle'],
|
||||
canLoginFromUi: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||
|
||||
expect(merged.providers.map((provider) => provider.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'opencode',
|
||||
]);
|
||||
expect(merged.authLoggedIn).toBe(false);
|
||||
expect(merged.authMethod).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCode runtime installer actions', () => {
|
||||
|
|
@ -706,7 +802,6 @@ describe('cliInstallerSlice', () => {
|
|||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
anthropic: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
});
|
||||
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
|
||||
|
|
@ -786,7 +881,6 @@ describe('cliInstallerSlice', () => {
|
|||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
anthropic: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
});
|
||||
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -806,7 +900,6 @@ describe('cliInstallerSlice', () => {
|
|||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
anthropic: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
});
|
||||
expect(
|
||||
|
|
@ -896,7 +989,6 @@ describe('cliInstallerSlice', () => {
|
|||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
anthropic: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
});
|
||||
expect(
|
||||
|
|
@ -983,6 +1075,72 @@ describe('cliInstallerSlice', () => {
|
|||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores hidden Gemini provider failures without keeping global auth checking active', async () => {
|
||||
useStore.setState({
|
||||
cliStatus: createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
models: ['claude-sonnet-4-5'],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
vi.mocked(api.cliInstaller.getProviderStatus).mockRejectedValue(
|
||||
new Error('Gemini status unavailable')
|
||||
);
|
||||
|
||||
await useStore.getState().fetchCliProviderStatus('gemini');
|
||||
|
||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
gemini: false,
|
||||
});
|
||||
expect(useStore.getState().cliStatus?.authLoggedIn).toBe(true);
|
||||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
||||
expect(
|
||||
useStore
|
||||
.getState()
|
||||
.cliStatus?.providers.find((provider) => provider.providerId === 'gemini')
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores hidden Gemini provider success responses in multimodel frontend state', async () => {
|
||||
useStore.setState({
|
||||
cliStatus: createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
models: ['claude-sonnet-4-5'],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
vi.mocked(api.cliInstaller.getProviderStatus).mockResolvedValue(
|
||||
createMultimodelProvider({
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
authenticated: true,
|
||||
authMethod: 'gemini_api_key',
|
||||
models: ['gemini-2.5-pro'],
|
||||
})
|
||||
);
|
||||
|
||||
await useStore.getState().fetchCliProviderStatus('gemini');
|
||||
|
||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
gemini: false,
|
||||
});
|
||||
expect(useStore.getState().cliStatus?.authLoggedIn).toBe(false);
|
||||
expect(useStore.getState().cliStatus?.authMethod).toBeNull();
|
||||
expect(
|
||||
useStore
|
||||
.getState()
|
||||
.cliStatus?.providers.find((provider) => provider.providerId === 'gemini')
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks authStatusChecking true while a multimodel provider refresh is in flight and clears it on success', async () => {
|
||||
let resolveProviderStatus!: (value: CliInstallationStatus['providers'][number]) => void;
|
||||
const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>(
|
||||
|
|
@ -1037,6 +1195,83 @@ describe('cliInstallerSlice', () => {
|
|||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps cached catalog on summary-only provider refresh without stale auth', async () => {
|
||||
const currentProvider = createMultimodelProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
models: ['gpt-5.4'],
|
||||
modelCatalogRefreshState: 'ready',
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'codex',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-17T00:00:00.000Z',
|
||||
staleAt: '2026-05-17T00:10:00.000Z',
|
||||
defaultModelId: 'gpt-5.4',
|
||||
defaultLaunchModel: 'gpt-5.4',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
launchModel: 'gpt-5.4',
|
||||
displayName: 'GPT-5.4',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['medium'],
|
||||
defaultReasoningEffort: 'medium',
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: false,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'skipped',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useStore.setState({
|
||||
cliStatus: createMultimodelStatus([currentProvider]),
|
||||
});
|
||||
vi.mocked(api.cliInstaller.getProviderStatus).mockResolvedValue(
|
||||
createMultimodelProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'Not connected',
|
||||
models: [],
|
||||
modelCatalog: null,
|
||||
modelCatalogRefreshState: 'loading',
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: {
|
||||
dynamic: true,
|
||||
source: 'app-server',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await useStore.getState().fetchCliProviderStatus('codex');
|
||||
|
||||
const provider = useStore
|
||||
.getState()
|
||||
.cliStatus?.providers.find((candidate) => candidate.providerId === 'codex');
|
||||
expect(provider).toMatchObject({
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'Not connected',
|
||||
models: ['gpt-5.4'],
|
||||
modelCatalogRefreshState: 'ready',
|
||||
});
|
||||
expect(provider?.modelCatalog?.defaultModelId).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('keeps OpenCode refresh status-only even when model verification is requested', async () => {
|
||||
const nextProvider = createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
|
|
|
|||
|
|
@ -4323,6 +4323,102 @@ describe('teamSlice actions', () => {
|
|||
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
|
||||
});
|
||||
|
||||
it('does not crash when runtime resource history contains malformed samples', async () => {
|
||||
const store = createSliceStore();
|
||||
const validSample = {
|
||||
timestamp: '2026-03-12T10:00:00.000Z',
|
||||
rssBytes: 256 * 1024 * 1024,
|
||||
cpuPercent: 4,
|
||||
pid: 4242,
|
||||
};
|
||||
const snapshot = createRuntimeSnapshot({
|
||||
members: {
|
||||
alice: {
|
||||
...createRuntimeSnapshot().members.alice,
|
||||
cpuPercent: 4,
|
||||
resourceHistory: [null, validSample] as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
|
||||
|
||||
await store.getState().fetchTeamAgentRuntime('my-team');
|
||||
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
|
||||
|
||||
const semanticallySameSnapshot = createRuntimeSnapshot({
|
||||
members: {
|
||||
alice: {
|
||||
...snapshot.members.alice,
|
||||
resourceHistory: [null, { ...validSample }] as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
hoisted.getTeamAgentRuntime.mockResolvedValue(semanticallySameSnapshot);
|
||||
|
||||
await store.getState().fetchTeamAgentRuntime('my-team');
|
||||
|
||||
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBe(firstSnapshot);
|
||||
});
|
||||
|
||||
it('updates runtime snapshots when aggregate runtime load breakdown changes', async () => {
|
||||
const store = createSliceStore();
|
||||
const firstBreakdownHistorySample = {
|
||||
timestamp: '2026-03-12T10:00:00.000Z',
|
||||
rssBytes: 300 * 1024 * 1024,
|
||||
cpuPercent: 12,
|
||||
primaryCpuPercent: 12,
|
||||
primaryRssBytes: 300 * 1024 * 1024,
|
||||
processCount: 1,
|
||||
runtimeLoadScope: 'single-process',
|
||||
pid: 4242,
|
||||
};
|
||||
const snapshot = createRuntimeSnapshot({
|
||||
members: {
|
||||
alice: {
|
||||
...createRuntimeSnapshot().members.alice,
|
||||
cpuPercent: 12,
|
||||
rssBytes: 300 * 1024 * 1024,
|
||||
primaryCpuPercent: 12,
|
||||
primaryRssBytes: 300 * 1024 * 1024,
|
||||
processCount: 1,
|
||||
runtimeLoadScope: 'single-process',
|
||||
resourceHistory: [firstBreakdownHistorySample],
|
||||
},
|
||||
},
|
||||
});
|
||||
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
|
||||
|
||||
await store.getState().fetchTeamAgentRuntime('my-team');
|
||||
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
|
||||
|
||||
const nextSnapshot = createRuntimeSnapshot({
|
||||
members: {
|
||||
alice: {
|
||||
...snapshot.members.alice,
|
||||
childCpuPercent: 8,
|
||||
childRssBytes: 80 * 1024 * 1024,
|
||||
processCount: 3,
|
||||
runtimeLoadScope: 'process-tree',
|
||||
resourceHistory: [
|
||||
{
|
||||
...firstBreakdownHistorySample,
|
||||
childCpuPercent: 8,
|
||||
childRssBytes: 80 * 1024 * 1024,
|
||||
processCount: 3,
|
||||
runtimeLoadScope: 'process-tree',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot);
|
||||
|
||||
await store.getState().fetchTeamAgentRuntime('my-team');
|
||||
|
||||
expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot);
|
||||
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
|
||||
});
|
||||
|
||||
it('updates runtime snapshots when copy-diagnostics details change', async () => {
|
||||
const store = createSliceStore();
|
||||
const snapshot = createRuntimeSnapshot({
|
||||
|
|
|
|||
|
|
@ -806,6 +806,31 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
).toContain('Anthropic authentication error');
|
||||
});
|
||||
|
||||
it('renders timed OpenCode quota errors with retry/reset context', () => {
|
||||
const advisory = {
|
||||
kind: 'api_error' as const,
|
||||
observedAt: '2026-05-17T21:44:34.000Z',
|
||||
retryUntil: '2026-05-18T00:00:00.502Z',
|
||||
retryDelayMs: 8_126_502,
|
||||
reasonCode: 'quota_exhausted' as const,
|
||||
message:
|
||||
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.502Z',
|
||||
};
|
||||
|
||||
expect(
|
||||
getMemberRuntimeAdvisoryLabel(
|
||||
advisory,
|
||||
'opencode',
|
||||
Date.parse('2026-05-17T21:45:00.000Z')
|
||||
)
|
||||
).toBe('OpenCode quota error · retry 2h 15m');
|
||||
|
||||
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
|
||||
expect(title).toContain('OpenCode quota exhausted.');
|
||||
expect(title).toContain('Waiting for OpenCode retry or quota reset around 00:00 UTC.');
|
||||
expect(title).toContain('Free usage exceeded');
|
||||
});
|
||||
|
||||
it('formats raw OpenCode protocol advisory reasons before showing them in titles', () => {
|
||||
const advisory = {
|
||||
kind: 'api_error' as const,
|
||||
|
|
|
|||
|
|
@ -90,4 +90,28 @@ describe('member launch diagnostics', () => {
|
|||
);
|
||||
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"');
|
||||
});
|
||||
|
||||
it('includes runtime advisory evidence in copy diagnostics', () => {
|
||||
const payload = buildMemberLaunchDiagnosticsPayload({
|
||||
memberName: 'alice',
|
||||
runtimeAdvisoryLabel: 'OpenCode delivery error',
|
||||
runtimeAdvisoryTitle: 'OpenCode accepted the prompt, but no assistant turn was recorded.',
|
||||
runtimeAdvisory: {
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-05-17T22:11:38.239Z',
|
||||
reasonCode: 'backend_error',
|
||||
message: 'OpenCode accepted the prompt, but no assistant turn was recorded.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.memberCardError).toBe(
|
||||
'OpenCode accepted the prompt, but no assistant turn was recorded.'
|
||||
);
|
||||
expect(payload.runtimeAdvisoryKind).toBe('api_error');
|
||||
expect(payload.runtimeAdvisoryReasonCode).toBe('backend_error');
|
||||
expect(payload.diagnostics).toContain(
|
||||
'OpenCode accepted the prompt, but no assistant turn was recorded.'
|
||||
);
|
||||
expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import * as path from 'path';
|
|||
|
||||
import { afterEach, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
// Mock Sentry Electron SDK — it requires the real `electron` package at import
|
||||
// Mock Sentry Electron SDK - it requires the real `electron` package at import
|
||||
// time which is unavailable in the vitest/happy-dom environment.
|
||||
const sentryNoOp = {
|
||||
init: vi.fn(),
|
||||
|
|
@ -17,6 +17,7 @@ const sentryNoOp = {
|
|||
captureException: vi.fn(),
|
||||
setUser: vi.fn(),
|
||||
setTags: vi.fn(),
|
||||
close: vi.fn(() => Promise.resolve(true)),
|
||||
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
|
||||
withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.fn() })),
|
||||
browserTracingIntegration: vi.fn(() => ({
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export default defineConfig({
|
|||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
'@preload': resolve(__dirname, 'src/preload'),
|
||||
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts'),
|
||||
'@radix-ui/react-compose-refs': resolve(
|
||||
__dirname,
|
||||
'src/renderer/vendor/radixComposeRefs.ts'
|
||||
),
|
||||
react: resolve(__dirname, 'node_modules/react'),
|
||||
'react-dom': resolve(__dirname, 'node_modules/react-dom'),
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue