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}"
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
pnpm pkg set version="$VERSION"
|
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
|
- name: Build app
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
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_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: quant-jump-pro
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: electron
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: pnpm build
|
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|
@ -282,14 +300,32 @@ jobs:
|
||||||
node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}"
|
node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}"
|
||||||
fi
|
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 }})
|
- name: Build app (macOS ${{ matrix.arch }})
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
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_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: quant-jump-pro
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: electron
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: pnpm build
|
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||||
|
|
||||||
- name: Verify packaged inputs (macOS ${{ matrix.arch }})
|
- name: Verify packaged inputs (macOS ${{ matrix.arch }})
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -381,14 +417,32 @@ jobs:
|
||||||
node ./scripts/stage-runtime.mjs --platform win32-x64
|
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)
|
- name: Build app (Windows)
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
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_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: quant-jump-pro
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: electron
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: pnpm build
|
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||||
|
|
||||||
- name: Verify packaged inputs (Windows)
|
- name: Verify packaged inputs (Windows)
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -483,14 +537,32 @@ jobs:
|
||||||
node ./scripts/stage-runtime.mjs --platform linux-x64
|
node ./scripts/stage-runtime.mjs --platform linux-x64
|
||||||
fi
|
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)
|
- name: Build app (Linux)
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
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_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: quant-jump-pro
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: electron
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: pnpm build
|
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||||
|
|
||||||
- name: Verify packaged inputs (Linux)
|
- name: Verify packaged inputs (Linux)
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -65,3 +65,4 @@ remotion/*
|
||||||
|
|
||||||
# Local reference captures
|
# Local reference captures
|
||||||
/agent-teams-reference-fix-*.png
|
/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 sentrySourceMapTargets = {
|
||||||
const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
|
main: {
|
||||||
? [
|
assets: ['./dist-electron/main/**/*.{js,cjs,mjs,map}'],
|
||||||
sentryVitePlugin({
|
filesToDeleteAfterUpload: ['./dist-electron/main/**/*.map'],
|
||||||
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
|
},
|
||||||
project: process.env.SENTRY_PROJECT ?? 'electron',
|
renderer: {
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
assets: ['./out/renderer/**/*.{js,cjs,mjs,map}'],
|
||||||
release: { name: `agent-teams-ai@${pkg.version}` },
|
filesToDeleteAfterUpload: ['./out/renderer/**/*.map'],
|
||||||
sourcemaps: {
|
},
|
||||||
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.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({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [
|
plugins: [
|
||||||
nativeModuleStub(),
|
nativeModuleStub(),
|
||||||
...sentryPlugins,
|
...createSentryPlugins('main'),
|
||||||
],
|
],
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
__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).
|
// at runtime in packaged Electron apps (only during CI build).
|
||||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''),
|
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''),
|
||||||
},
|
},
|
||||||
|
|
@ -148,10 +160,14 @@ export default defineConfig({
|
||||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||||
'@shared': resolve(__dirname, 'src/shared'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@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')
|
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react(), ...sentryPlugins],
|
plugins: [react(), ...createSentryPlugins('renderer')],
|
||||||
build: {
|
build: {
|
||||||
sourcemap: 'hidden',
|
sourcemap: 'hidden',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|
|
||||||
|
|
@ -1259,17 +1259,17 @@
|
||||||
|
|
||||||
.cyber-feature-rail__reviewer-bubble {
|
.cyber-feature-rail__reviewer-bubble {
|
||||||
--reviewer-bubble-center-shift: 3px;
|
--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;
|
left: auto;
|
||||||
top: auto;
|
top: auto;
|
||||||
right: calc(var(--reviewer-robot-width) / 2);
|
right: calc(var(--reviewer-robot-width) / 2);
|
||||||
bottom: calc(100% + 10px);
|
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: translateX(calc(50% + var(--reviewer-bubble-center-shift))) translate3d(0, 0, 0) rotate(-4deg);
|
||||||
transform-origin: center bottom;
|
transform-origin: center bottom;
|
||||||
animation: cyberFeatureReviewerSpeechFloat 2.8s ease-in-out 0.45s infinite;
|
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"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Transition name="cyber-feature-bubble">
|
<Transition name="cyber-feature-bubble">
|
||||||
<CyberHeroSpeechBubble
|
<RobotSpeechBubble
|
||||||
v-if="reviewerBubbleText"
|
v-if="reviewerBubbleText"
|
||||||
class="cyber-feature-rail__reviewer-bubble"
|
class="cyber-feature-rail__reviewer-bubble"
|
||||||
role="reviewer"
|
tail="down"
|
||||||
>
|
>
|
||||||
{{ reviewerBubbleText }}
|
{{ reviewerBubbleText }}
|
||||||
</CyberHeroSpeechBubble>
|
</RobotSpeechBubble>
|
||||||
</Transition>
|
</Transition>
|
||||||
<div class="cyber-feature-rail__reviewer-card cyber-panel">
|
<div class="cyber-feature-rail__reviewer-card cyber-panel">
|
||||||
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>
|
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,9 @@ const docsHref = computed(() => {
|
||||||
<template>
|
<template>
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="app-footer__robot-stage">
|
<div class="app-footer__robot-stage">
|
||||||
<span class="app-footer__robot-bubble">
|
<RobotSpeechBubble class="app-footer__robot-bubble" tail="down">
|
||||||
<svg
|
{{ t('footer.robotBubble') }}
|
||||||
class="app-footer__robot-bubble-shape"
|
</RobotSpeechBubble>
|
||||||
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>
|
|
||||||
<img
|
<img
|
||||||
class="app-footer__robot"
|
class="app-footer__robot"
|
||||||
:src="robotLeadLounge"
|
:src="robotLeadLounge"
|
||||||
|
|
@ -82,51 +71,17 @@ const docsHref = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-footer__robot-bubble {
|
.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;
|
top: -28px;
|
||||||
left: -18px;
|
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: rotate(-2deg);
|
||||||
transform-origin: 72% 74%;
|
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 {
|
.app-footer__inner {
|
||||||
|
|
|
||||||
|
|
@ -317,12 +317,13 @@ function getStatusIcon(status: string): string {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Transition name="comparison-robot-bubble">
|
<Transition name="comparison-robot-bubble">
|
||||||
<span
|
<RobotSpeechBubble
|
||||||
v-if="showComparisonRobotBubble"
|
v-if="showComparisonRobotBubble"
|
||||||
class="comparison-table__robot-bubble"
|
class="comparison-table__robot-bubble"
|
||||||
|
tail="right"
|
||||||
>
|
>
|
||||||
{{ t("comparison.robotBubble") }}
|
{{ t("comparison.robotBubble") }}
|
||||||
</span>
|
</RobotSpeechBubble>
|
||||||
</Transition>
|
</Transition>
|
||||||
<img
|
<img
|
||||||
class="comparison-table__robot-image"
|
class="comparison-table__robot-image"
|
||||||
|
|
@ -487,59 +488,20 @@ function getStatusIcon(status: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparison-table__robot-bubble {
|
.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;
|
top: 10px;
|
||||||
right: calc(100% + 12px);
|
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: rotate(-5deg);
|
||||||
transform-origin: right bottom;
|
transform-origin: right bottom;
|
||||||
animation: comparisonRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
|
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-enter-active,
|
||||||
.comparison-robot-bubble-leave-active {
|
.comparison-robot-bubble-leave-active {
|
||||||
transition:
|
transition:
|
||||||
|
|
|
||||||
|
|
@ -314,12 +314,13 @@ const releaseDate = computed(() => {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Transition name="download-robot-bubble">
|
<Transition name="download-robot-bubble">
|
||||||
<span
|
<RobotSpeechBubble
|
||||||
v-if="showLinuxRobotMessage"
|
v-if="showLinuxRobotMessage"
|
||||||
class="download-section__card-robot-bubble"
|
class="download-section__card-robot-bubble"
|
||||||
|
tail="right"
|
||||||
>
|
>
|
||||||
Готов начать!
|
Готов начать!
|
||||||
</span>
|
</RobotSpeechBubble>
|
||||||
</Transition>
|
</Transition>
|
||||||
<img
|
<img
|
||||||
class="download-section__card-robot"
|
class="download-section__card-robot"
|
||||||
|
|
@ -617,60 +618,20 @@ const releaseDate = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-section__card-robot-bubble {
|
.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;
|
top: 12px;
|
||||||
right: calc(100% - 18px);
|
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: rotate(-5deg);
|
||||||
transform-origin: right bottom;
|
transform-origin: right bottom;
|
||||||
animation: downloadRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
|
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-enter-active,
|
||||||
.download-robot-bubble-leave-active {
|
.download-robot-bubble-leave-active {
|
||||||
transition:
|
transition:
|
||||||
|
|
@ -970,9 +931,9 @@ const releaseDate = computed(() => {
|
||||||
.download-section__card-robot-bubble {
|
.download-section__card-robot-bubble {
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: calc(100% - 14px);
|
right: calc(100% - 14px);
|
||||||
min-height: 28px;
|
--robot-bubble-min-width: 88px;
|
||||||
padding: 6px 9px;
|
--robot-bubble-font-size: 0.6rem;
|
||||||
font-size: 0.6rem;
|
--robot-bubble-padding: 7px 23px 7px 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-section__card-robot {
|
.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);
|
return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
|
export async function listRuntimeProcessTableForCurrentPlatform(): Promise<
|
||||||
RuntimeProcessTableRow[]
|
RuntimeProcessTableRow[]
|
||||||
> {
|
> {
|
||||||
return runtimeCommandExecutor.listRuntimeProcesses();
|
return runtimeCommandExecutor.listRuntimeProcesses();
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export {
|
||||||
isTmuxRuntimeReadyForCurrentPlatform,
|
isTmuxRuntimeReadyForCurrentPlatform,
|
||||||
killTmuxPaneForCurrentPlatform,
|
killTmuxPaneForCurrentPlatform,
|
||||||
killTmuxPaneForCurrentPlatformSync,
|
killTmuxPaneForCurrentPlatformSync,
|
||||||
listRuntimeProcessesForCurrentTmuxPlatform,
|
listRuntimeProcessTableForCurrentPlatform,
|
||||||
listTmuxPanePidsForCurrentPlatform,
|
listTmuxPanePidsForCurrentPlatform,
|
||||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||||
sendKeysToTmuxPaneForCurrentPlatform,
|
sendKeysToTmuxPaneForCurrentPlatform,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,21 @@ let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||||
const STATUS_CACHE_TTL_MS = 5_000;
|
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.
|
* Initializes CLI installer handlers with the service instance.
|
||||||
|
|
@ -122,6 +137,13 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cachedStatus.value.flavor === 'agent_teams_orchestrator' &&
|
||||||
|
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hasProvider = cachedStatus.value.providers.some(
|
const hasProvider = cachedStatus.value.providers.some(
|
||||||
(provider) => provider.providerId === providerStatus.providerId
|
(provider) => provider.providerId === providerStatus.providerId
|
||||||
);
|
);
|
||||||
|
|
@ -130,13 +152,19 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo
|
||||||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||||
)
|
)
|
||||||
: [...cachedStatus.value.providers, providerStatus];
|
: [...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 = {
|
cachedStatus = {
|
||||||
value: {
|
value: {
|
||||||
...cachedStatus.value,
|
...cachedStatus.value,
|
||||||
providers: nextProviders,
|
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,
|
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||||
},
|
},
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ import {
|
||||||
registerTerminalHandlers,
|
registerTerminalHandlers,
|
||||||
removeTerminalHandlers,
|
removeTerminalHandlers,
|
||||||
} from './terminal';
|
} from './terminal';
|
||||||
|
import { registerTelemetryHandlers, removeTelemetryHandlers } from './telemetry';
|
||||||
import { registerTmuxHandlers, removeTmuxHandlers } from './tmux';
|
import { registerTmuxHandlers, removeTmuxHandlers } from './tmux';
|
||||||
import {
|
import {
|
||||||
initializeUpdaterHandlers,
|
initializeUpdaterHandlers,
|
||||||
|
|
@ -268,6 +269,7 @@ export function initializeIpcHandlers(
|
||||||
registerWindowHandlers(ipcMain);
|
registerWindowHandlers(ipcMain);
|
||||||
registerRendererLogHandlers(ipcMain);
|
registerRendererLogHandlers(ipcMain);
|
||||||
registerScheduleHandlers(ipcMain);
|
registerScheduleHandlers(ipcMain);
|
||||||
|
registerTelemetryHandlers(ipcMain);
|
||||||
if (cliInstaller) {
|
if (cliInstaller) {
|
||||||
registerCliInstallerHandlers(ipcMain);
|
registerCliInstallerHandlers(ipcMain);
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +317,7 @@ export function removeIpcHandlers(): void {
|
||||||
removeWindowHandlers(ipcMain);
|
removeWindowHandlers(ipcMain);
|
||||||
removeRendererLogHandlers(ipcMain);
|
removeRendererLogHandlers(ipcMain);
|
||||||
removeScheduleHandlers(ipcMain);
|
removeScheduleHandlers(ipcMain);
|
||||||
|
removeTelemetryHandlers(ipcMain);
|
||||||
removeCliInstallerHandlers(ipcMain);
|
removeCliInstallerHandlers(ipcMain);
|
||||||
removeOpenCodeRuntimeHandlers(ipcMain);
|
removeOpenCodeRuntimeHandlers(ipcMain);
|
||||||
removeCodexRuntimeHandlers(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,
|
ensureAgentTeamsClientIdentity,
|
||||||
getSentryAnonymousUserId,
|
getSentryAnonymousUserId,
|
||||||
} from '@main/services/identity/AgentTeamsIdentityStore';
|
} from '@main/services/identity/AgentTeamsIdentityStore';
|
||||||
|
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
import {
|
import {
|
||||||
|
filterSafeSentryIntegrations,
|
||||||
isValidDsn,
|
isValidDsn,
|
||||||
|
redactSentryEvent,
|
||||||
SENTRY_ENVIRONMENT,
|
SENTRY_ENVIRONMENT,
|
||||||
SENTRY_RELEASE,
|
SENTRY_RELEASE,
|
||||||
TRACES_SAMPLE_RATE,
|
TRACES_SAMPLE_RATE,
|
||||||
} from '@shared/utils/sentryConfig';
|
} from '@shared/utils/sentryConfig';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Telemetry gate
|
// Telemetry gate
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Module-level flag that `beforeSend` checks.
|
const CONFIG_FILENAME = 'agent-teams-config.json';
|
||||||
// Updated by `syncTelemetryFlag()` once ConfigManager is ready.
|
const LEGACY_CONFIG_FILENAMES = [
|
||||||
// Defaults to `true` so early crash reports are NOT silently dropped;
|
'claude-devtools-config.json',
|
||||||
// if the user later turns telemetry off, the flag flips to `false`.
|
'claude-code-context-config.json',
|
||||||
let telemetryAllowed = true;
|
] 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;
|
let telemetryIdentitySyncToken = 0;
|
||||||
|
|
||||||
export function getSafeSentryTelemetryTags(
|
export function getSafeSentryTelemetryTags(
|
||||||
|
|
@ -50,21 +97,29 @@ export function getSafeSentryTelemetryTags(
|
||||||
*/
|
*/
|
||||||
export function syncTelemetryFlag(enabled: boolean): void {
|
export function syncTelemetryFlag(enabled: boolean): void {
|
||||||
telemetryAllowed = enabled;
|
telemetryAllowed = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
telemetryIdentitySyncToken++;
|
||||||
|
shutdownSentry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeSentryIfAllowed();
|
||||||
void syncTelemetryIdentity();
|
void syncTelemetryIdentity();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterSentryEventForTelemetry(event: unknown): unknown {
|
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 {
|
interface SentryMainApi {
|
||||||
init?: (options: SentryInitOptions) => void;
|
init?: (options: SentryInitOptions) => void;
|
||||||
setUser?: (user: { id: string } | null) => void;
|
setUser?: (user: { id: string } | null) => void;
|
||||||
setTags?: (tags: Record<string, string>) => void;
|
setTags?: (tags: Record<string, string>) => void;
|
||||||
|
close?: (timeout?: number) => PromiseLike<boolean> | boolean;
|
||||||
addBreadcrumb?: (breadcrumb: {
|
addBreadcrumb?: (breadcrumb: {
|
||||||
category: string;
|
category: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -82,6 +137,9 @@ interface SentryInitOptions {
|
||||||
sendDefaultPii: false;
|
sendDefaultPii: false;
|
||||||
beforeSend: (event: unknown) => unknown;
|
beforeSend: (event: unknown) => unknown;
|
||||||
beforeSendTransaction: (event: unknown) => unknown;
|
beforeSendTransaction: (event: unknown) => unknown;
|
||||||
|
integrations: <TIntegration extends { name?: string }>(
|
||||||
|
integrations: TIntegration[]
|
||||||
|
) => TIntegration[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let Sentry: SentryMainApi | null = null;
|
let Sentry: SentryMainApi | null = null;
|
||||||
|
|
@ -98,6 +156,41 @@ function clearSentryUser(): void {
|
||||||
Sentry.setUser?.(null);
|
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> {
|
async function syncTelemetryIdentity(): Promise<void> {
|
||||||
const syncToken = ++telemetryIdentitySyncToken;
|
const syncToken = ++telemetryIdentitySyncToken;
|
||||||
if (!initialized || !Sentry) {
|
if (!initialized || !Sentry) {
|
||||||
|
|
@ -110,13 +203,18 @@ async function syncTelemetryIdentity(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const identity = await ensureAgentTeamsClientIdentity();
|
const context = await getCurrentSentryTelemetryContext();
|
||||||
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
|
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Sentry.setUser?.({ id: getSentryAnonymousUserId(identity.clientId) });
|
if (!context) {
|
||||||
Sentry.setTags?.(getSafeSentryTelemetryTags(identity.source));
|
clearSentryUser();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.setUser?.({ id: context.userId });
|
||||||
|
Sentry.setTags?.(context.tags);
|
||||||
} catch {
|
} catch {
|
||||||
if (syncToken === telemetryIdentitySyncToken) {
|
if (syncToken === telemetryIdentitySyncToken) {
|
||||||
clearSentryUser();
|
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 {
|
try {
|
||||||
// Dynamic import would be cleaner but top-level await is not available
|
// Dynamic import would be cleaner but top-level await is not available
|
||||||
// in all contexts. require() is synchronous and works in both Electron
|
// 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.
|
// module is not resolvable.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
|
||||||
Sentry = require('@sentry/electron/main') as SentryMainApi;
|
Sentry = require('@sentry/electron/main') as SentryMainApi;
|
||||||
|
|
@ -143,15 +248,21 @@ if (isValidDsn(dsn)) {
|
||||||
|
|
||||||
beforeSend: filterSentryEventForTelemetry,
|
beforeSend: filterSentryEventForTelemetry,
|
||||||
beforeSendTransaction: filterSentryEventForTelemetry,
|
beforeSendTransaction: filterSentryEventForTelemetry,
|
||||||
|
integrations: filterSafeSentryIntegrations,
|
||||||
});
|
});
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
void syncTelemetryIdentity();
|
||||||
} catch {
|
} 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
|
// standalone (pure Node.js) mode. All exported helpers are no-ops when
|
||||||
// initialized is false, so this is safe to swallow.
|
// initialized is false, so this is safe to swallow.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeSentryIfAllowed();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public helpers (no-op when Sentry is not configured)
|
// Public helpers (no-op when Sentry is not configured)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -160,10 +271,10 @@ if (isValidDsn(dsn)) {
|
||||||
export function addMainBreadcrumb(
|
export function addMainBreadcrumb(
|
||||||
category: string,
|
category: string,
|
||||||
message: string,
|
message: string,
|
||||||
data?: Record<string, unknown>
|
_data?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
if (!initialized) return;
|
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 {
|
import {
|
||||||
getCachedShellEnv,
|
getCachedShellEnv,
|
||||||
getShellPreferredHome,
|
getShellPreferredHome,
|
||||||
resolveInteractiveShellEnv,
|
resolveInteractiveShellEnvBestEffort,
|
||||||
} from '@main/utils/shellEnv';
|
} from '@main/utils/shellEnv';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
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';
|
'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases';
|
||||||
|
|
||||||
const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress';
|
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) */
|
/** Timeout for `claude --version` (ms) */
|
||||||
const VERSION_TIMEOUT_MS = 10_000;
|
const VERSION_TIMEOUT_MS = 10_000;
|
||||||
|
|
@ -152,6 +193,7 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
||||||
providers: status.providers.map((provider) => ({
|
providers: status.providers.map((provider) => ({
|
||||||
...provider,
|
...provider,
|
||||||
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||||
|
modelCatalogRefreshState: provider.modelCatalogRefreshState ?? 'idle',
|
||||||
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
|
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
|
||||||
detailMessage: provider.detailMessage ?? null,
|
detailMessage: provider.detailMessage ?? null,
|
||||||
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
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(
|
function cloneProviderModelAvailability(
|
||||||
modelAvailability: CliProviderModelAvailability[] | undefined
|
modelAvailability: CliProviderModelAvailability[] | undefined
|
||||||
): CliProviderModelAvailability[] {
|
): CliProviderModelAvailability[] {
|
||||||
|
|
@ -485,27 +547,9 @@ export class CliInstallerService {
|
||||||
const ui = getCliFlavorUiOptions(flavor);
|
const ui = getCliFlavorUiOptions(flavor);
|
||||||
const providers =
|
const providers =
|
||||||
flavor === 'agent_teams_orchestrator'
|
flavor === 'agent_teams_orchestrator'
|
||||||
? (
|
? FRONTEND_MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
|
||||||
[
|
providerId,
|
||||||
{
|
displayName: getProviderDisplayName(providerId),
|
||||||
providerId: 'anthropic',
|
|
||||||
displayName: 'Anthropic',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
providerId: 'codex',
|
|
||||||
displayName: 'Codex',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
providerId: 'gemini',
|
|
||||||
displayName: 'Gemini',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
providerId: 'opencode',
|
|
||||||
displayName: 'OpenCode (200+ models)',
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
).map((provider) => ({
|
|
||||||
...provider,
|
|
||||||
supported: false,
|
supported: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
|
|
@ -514,7 +558,7 @@ export class CliInstallerService {
|
||||||
statusMessage: 'Checking...',
|
statusMessage: 'Checking...',
|
||||||
models: [],
|
models: [],
|
||||||
modelAvailability: [],
|
modelAvailability: [],
|
||||||
canLoginFromUi: provider.providerId !== 'opencode',
|
canLoginFromUi: providerId !== 'opencode',
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: false,
|
oneShot: false,
|
||||||
|
|
@ -652,6 +696,13 @@ export class CliInstallerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateLatestProviderStatus(providerStatus: CliProviderStatus): void {
|
private updateLatestProviderStatus(providerStatus: CliProviderStatus): void {
|
||||||
|
if (
|
||||||
|
this.latestStatusSnapshot?.flavor === 'agent_teams_orchestrator' &&
|
||||||
|
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
providerStatus.modelVerificationState !== 'verifying' &&
|
providerStatus.modelVerificationState !== 'verifying' &&
|
||||||
(providerStatus.modelAvailability?.length ?? 0) <= 0
|
(providerStatus.modelAvailability?.length ?? 0) <= 0
|
||||||
|
|
@ -668,15 +719,17 @@ export class CliInstallerService {
|
||||||
);
|
);
|
||||||
const nextProviders = hasProvider
|
const nextProviders = hasProvider
|
||||||
? this.latestStatusSnapshot.providers.map((provider) =>
|
? this.latestStatusSnapshot.providers.map((provider) =>
|
||||||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
provider.providerId === providerStatus.providerId
|
||||||
|
? mergeProviderStatusCatalogCache(providerStatus, provider)
|
||||||
|
: provider
|
||||||
)
|
)
|
||||||
: [...this.latestStatusSnapshot.providers, providerStatus];
|
: [...this.latestStatusSnapshot.providers, providerStatus];
|
||||||
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
|
const authenticatedProvider = getFrontendAuthenticatedProvider(nextProviders);
|
||||||
|
|
||||||
this.latestStatusSnapshot = {
|
this.latestStatusSnapshot = {
|
||||||
...this.latestStatusSnapshot,
|
...this.latestStatusSnapshot,
|
||||||
providers: nextProviders,
|
providers: nextProviders,
|
||||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
authLoggedIn: hasFrontendAuthenticatedProvider(nextProviders),
|
||||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -724,7 +777,7 @@ export class CliInstallerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||||
|
|
||||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
|
|
@ -744,14 +797,20 @@ export class CliInstallerService {
|
||||||
|
|
||||||
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
||||||
binaryPath,
|
binaryPath,
|
||||||
providerId
|
providerId,
|
||||||
|
(hydratedProviderStatus) => {
|
||||||
|
this.updateLatestProviderStatus(hydratedProviderStatus);
|
||||||
|
if (this.latestStatusSnapshot) {
|
||||||
|
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
this.updateLatestProviderStatus(providerStatus);
|
this.updateLatestProviderStatus(providerStatus);
|
||||||
return providerStatus;
|
return providerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||||
|
|
||||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
|
|
@ -813,7 +872,7 @@ export class CliInstallerService {
|
||||||
diag: CliInstallerStatusRunDiag
|
diag: CliInstallerStatusRunDiag
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
resetGatherDiag(diag);
|
resetGatherDiag(diag);
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||||
|
|
||||||
const r = ref.current;
|
const r = ref.current;
|
||||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
|
|
@ -952,6 +1011,7 @@ export class CliInstallerService {
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'error',
|
verificationState: 'error',
|
||||||
modelVerificationState: 'idle',
|
modelVerificationState: 'idle',
|
||||||
|
modelCatalogRefreshState: 'error',
|
||||||
statusMessage: message,
|
statusMessage: message,
|
||||||
models: [],
|
models: [],
|
||||||
modelAvailability: [],
|
modelAvailability: [],
|
||||||
|
|
@ -979,17 +1039,18 @@ export class CliInstallerService {
|
||||||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
||||||
binaryPath,
|
binaryPath,
|
||||||
(providersSnapshot) => {
|
(providersSnapshot) => {
|
||||||
result.providers = providersSnapshot;
|
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
||||||
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
|
result.providers = frontendProviders;
|
||||||
|
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||||
result.authMethod =
|
result.authMethod =
|
||||||
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
|
getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||||
this.publishStatusSnapshot(result);
|
this.publishStatusSnapshot(result);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
result.providers = providers;
|
const frontendProviders = filterFrontendMultimodelProviders(providers);
|
||||||
result.authLoggedIn = providers.some((provider) => provider.authenticated);
|
result.providers = frontendProviders;
|
||||||
result.authMethod =
|
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||||
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
result.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||||
result.authStatusChecking = false;
|
result.authStatusChecking = false;
|
||||||
this.publishStatusSnapshot(result);
|
this.publishStatusSnapshot(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { execCli } from '@main/utils/childProcess';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import {
|
import {
|
||||||
createDefaultCliExtensionCapabilities,
|
createDefaultCliExtensionCapabilities,
|
||||||
|
|
@ -316,6 +316,7 @@ export interface OpenCodeRuntimeTranscriptLogMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'];
|
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'];
|
||||||
|
const DEFAULT_PROVIDER_STATUS_IDS: CliProviderId[] = ['anthropic', 'codex', 'opencode'];
|
||||||
|
|
||||||
function getProviderDisplayName(providerId: CliProviderId): string {
|
function getProviderDisplayName(providerId: CliProviderId): string {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
|
|
@ -353,6 +354,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'unknown',
|
verificationState: 'unknown',
|
||||||
modelVerificationState: 'idle',
|
modelVerificationState: 'idle',
|
||||||
|
modelCatalogRefreshState: 'idle',
|
||||||
statusMessage: null,
|
statusMessage: null,
|
||||||
detailMessage: null,
|
detailMessage: null,
|
||||||
models: [],
|
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(
|
function mapRuntimeSubscriptionRateLimitWindow(
|
||||||
window: RuntimeSubscriptionRateLimitWindowResponse | null | undefined
|
window: RuntimeSubscriptionRateLimitWindowResponse | null | undefined
|
||||||
): NonNullable<CliProviderSubscriptionRateLimitSnapshot['primary']> | null {
|
): NonNullable<CliProviderSubscriptionRateLimitSnapshot['primary']> | null {
|
||||||
|
|
@ -620,7 +633,64 @@ function mapRuntimeSubscriptionRateLimits(
|
||||||
return primary || secondary ? { primary, secondary } : null;
|
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 {
|
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(
|
private async buildCliEnv(
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||||
|
|
@ -658,6 +728,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
if (!runtimeStatus) {
|
if (!runtimeStatus) {
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
const modelCatalog = mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...provider,
|
...provider,
|
||||||
|
|
@ -700,7 +771,8 @@ export class ClaudeMultimodelBridgeService {
|
||||||
detailMessage: diagnostic.detailMessage ?? null,
|
detailMessage: diagnostic.detailMessage ?? null,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
models: extractModelIds(runtimeStatus.models),
|
models: extractModelIds(runtimeStatus.models),
|
||||||
modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog),
|
modelCatalog,
|
||||||
|
modelCatalogRefreshState: getRuntimeModelCatalogRefreshState(runtimeStatus, modelCatalog),
|
||||||
subscriptionRateLimits: mapRuntimeSubscriptionRateLimits(
|
subscriptionRateLimits: mapRuntimeSubscriptionRateLimits(
|
||||||
providerId,
|
providerId,
|
||||||
runtimeStatus.authMethod,
|
runtimeStatus.authMethod,
|
||||||
|
|
@ -774,9 +846,10 @@ export class ClaudeMultimodelBridgeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildProviderStatusesSnapshot(
|
private buildProviderStatusesSnapshot(
|
||||||
providers: Map<CliProviderId, CliProviderStatus>
|
providers: Map<CliProviderId, CliProviderStatus>,
|
||||||
|
providerIds: readonly CliProviderId[] = ORDERED_PROVIDER_IDS
|
||||||
): CliProviderStatus[] {
|
): CliProviderStatus[] {
|
||||||
return ORDERED_PROVIDER_IDS.map(
|
return providerIds.map(
|
||||||
(providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId)
|
(providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -785,59 +858,62 @@ export class ClaudeMultimodelBridgeService {
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
connectionIssues: Partial<Record<CliProviderId, string>>
|
connectionIssues: Partial<Record<CliProviderId, string>>,
|
||||||
|
options: { summary?: boolean } = {}
|
||||||
): Promise<CliProviderStatus> {
|
): Promise<CliProviderStatus> {
|
||||||
const { stdout } = await execCli(
|
const args = ['runtime', 'status', '--json', '--provider', providerId];
|
||||||
binaryPath,
|
if (options.summary) {
|
||||||
['runtime', 'status', '--json', '--provider', providerId],
|
args.push('--summary');
|
||||||
{
|
}
|
||||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
const { stdout } = await execCli(binaryPath, args, {
|
||||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||||
env,
|
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||||
}
|
env,
|
||||||
);
|
});
|
||||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||||
return providerConnectionService.enrichProviderStatus(
|
return providerConnectionService.enrichProviderStatus(
|
||||||
this.applyConnectionIssue(
|
this.applyConnectionIssue(
|
||||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
||||||
connectionIssues
|
connectionIssues
|
||||||
)
|
),
|
||||||
|
{ hydrateModelCatalog: options.summary !== true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getProviderStatusFromScopedRuntimeStatus(
|
private async getProviderStatusFromScopedRuntimeStatus(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
providerId: CliProviderId
|
providerId: CliProviderId,
|
||||||
|
options: { summary?: boolean } = {}
|
||||||
): Promise<CliProviderStatus> {
|
): Promise<CliProviderStatus> {
|
||||||
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
||||||
return this.getProviderStatusFromRuntimeStatusCommand(
|
return this.getProviderStatusFromRuntimeStatusCommand(
|
||||||
binaryPath,
|
binaryPath,
|
||||||
providerId,
|
providerId,
|
||||||
env,
|
env,
|
||||||
connectionIssues
|
connectionIssues,
|
||||||
|
options
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getProviderStatusesFromScopedRuntimeStatus(
|
private async getProviderStatusesFromScopedRuntimeStatus(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
onUpdate?: (providers: CliProviderStatus[]) => void,
|
||||||
|
options: { summary?: boolean; providerIds?: readonly CliProviderId[] } = {}
|
||||||
): Promise<CliProviderStatus[] | null> {
|
): Promise<CliProviderStatus[] | null> {
|
||||||
|
const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS;
|
||||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||||
ORDERED_PROVIDER_IDS.map((providerId) => [
|
providerIds.map((providerId) => [providerId, createPendingProviderStatus(providerId)])
|
||||||
providerId,
|
|
||||||
createPendingProviderStatus(providerId),
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
const failures: { providerId: CliProviderId; error: unknown }[] = [];
|
const failures: { providerId: CliProviderId; error: unknown }[] = [];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ORDERED_PROVIDER_IDS.map(async (providerId) => {
|
providerIds.map(async (providerId) => {
|
||||||
try {
|
try {
|
||||||
providers.set(
|
providers.set(
|
||||||
providerId,
|
providerId,
|
||||||
await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId)
|
await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId, options)
|
||||||
);
|
);
|
||||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
|
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push({ providerId, error });
|
failures.push({ providerId, error });
|
||||||
}
|
}
|
||||||
|
|
@ -845,10 +921,10 @@ export class ClaudeMultimodelBridgeService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (failures.length === 0) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -861,8 +937,65 @@ export class ClaudeMultimodelBridgeService {
|
||||||
for (const { providerId, error } of failures) {
|
for (const { providerId, error } of failures) {
|
||||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
||||||
}
|
}
|
||||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
|
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||||
return this.buildProviderStatusesSnapshot(providers);
|
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(
|
private async getOpenCodeVerifySnapshot(
|
||||||
|
|
@ -956,22 +1089,54 @@ export class ClaudeMultimodelBridgeService {
|
||||||
|
|
||||||
async getProviderStatus(
|
async getProviderStatus(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
providerId: CliProviderId
|
providerId: CliProviderId,
|
||||||
|
onCatalogUpdate?: (provider: CliProviderStatus) => void
|
||||||
): Promise<CliProviderStatus> {
|
): Promise<CliProviderStatus> {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||||
logger.warn(
|
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)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (providerId === 'gemini') {
|
||||||
|
return this.buildGeminiStatus(binaryPath);
|
||||||
|
}
|
||||||
|
|
||||||
const providers = await this.getProviderStatuses(binaryPath);
|
const providers = await this.getProviderStatuses(binaryPath);
|
||||||
return (
|
return (
|
||||||
providers.find((provider) => provider.providerId === providerId) ??
|
providers.find((provider) => provider.providerId === providerId) ??
|
||||||
|
|
@ -1129,16 +1294,39 @@ export class ClaudeMultimodelBridgeService {
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||||
): Promise<CliProviderStatus[]> {
|
): Promise<CliProviderStatus[]> {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||||
|
|
||||||
try {
|
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) {
|
if (providers) {
|
||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
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)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
|
|
@ -1155,7 +1343,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||||
const providers = await providerConnectionService.enrichProviderStatuses(
|
const providers = await providerConnectionService.enrichProviderStatuses(
|
||||||
this.applyConnectionIssues(
|
this.applyConnectionIssues(
|
||||||
ORDERED_PROVIDER_IDS.map((providerId) =>
|
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) =>
|
||||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
||||||
),
|
),
|
||||||
connectionIssues
|
connectionIssues
|
||||||
|
|
@ -1187,7 +1375,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||||
ORDERED_PROVIDER_IDS.map((providerId) => [
|
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) => [
|
||||||
providerId,
|
providerId,
|
||||||
createDefaultProviderStatus(providerId),
|
createDefaultProviderStatus(providerId),
|
||||||
])
|
])
|
||||||
|
|
@ -1196,7 +1384,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
if (statusResult.status === 'fulfilled') {
|
if (statusResult.status === 'fulfilled') {
|
||||||
try {
|
try {
|
||||||
const parsed = extractJsonObject<ProviderStatusCommandResponse>(statusResult.value.stdout);
|
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];
|
const runtimeStatus = parsed.providers?.[providerId];
|
||||||
if (!runtimeStatus) continue;
|
if (!runtimeStatus) continue;
|
||||||
providers.set(providerId, {
|
providers.set(providerId, {
|
||||||
|
|
@ -1226,7 +1414,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
onUpdate?.(DEFAULT_PROVIDER_STATUS_IDS.map((id) => providers.get(id)!));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -1241,26 +1429,26 @@ export class ClaudeMultimodelBridgeService {
|
||||||
? statusResult.reason.message
|
? statusResult.reason.message
|
||||||
: String(statusResult.reason);
|
: String(statusResult.reason);
|
||||||
logger.warn(`Provider auth status unavailable: ${message}`);
|
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.set(providerId, {
|
||||||
...providers.get(providerId)!,
|
...providers.get(providerId)!,
|
||||||
statusMessage: 'Provider status not supported by current claude-multimodel build',
|
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') {
|
if (modelsResult.status === 'fulfilled') {
|
||||||
try {
|
try {
|
||||||
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelsResult.value.stdout);
|
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);
|
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
|
||||||
if (runtimeModels.length === 0) continue;
|
if (runtimeModels.length === 0) continue;
|
||||||
providers.set(providerId, {
|
providers.set(providerId, {
|
||||||
...providers.get(providerId)!,
|
...providers.get(providerId)!,
|
||||||
models: runtimeModels,
|
models: runtimeModels,
|
||||||
});
|
});
|
||||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
onUpdate?.(DEFAULT_PROVIDER_STATUS_IDS.map((id) => providers.get(id)!));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
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(
|
const enrichedProviders = await providerConnectionService.enrichProviderStatuses(
|
||||||
this.applyConnectionIssues(
|
this.applyConnectionIssues(
|
||||||
ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!),
|
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) => providers.get(providerId)!),
|
||||||
connectionIssues
|
connectionIssues
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,10 @@ type AnthropicApiKeyVerifier = (
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
) => Promise<AnthropicApiKeyVerificationResult>;
|
) => Promise<AnthropicApiKeyVerificationResult>;
|
||||||
|
|
||||||
|
interface ProviderStatusEnrichmentOptions {
|
||||||
|
hydrateModelCatalog?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function hashCredentialForCache(value: string): string {
|
function hashCredentialForCache(value: string): string {
|
||||||
return crypto.createHash('sha256').update(value).digest('hex');
|
return crypto.createHash('sha256').update(value).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
@ -698,7 +702,10 @@ export class ProviderConnectionService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
|
async enrichProviderStatus(
|
||||||
|
provider: CliProviderStatus,
|
||||||
|
options: ProviderStatusEnrichmentOptions = {}
|
||||||
|
): Promise<CliProviderStatus> {
|
||||||
const withConnection = {
|
const withConnection = {
|
||||||
...provider,
|
...provider,
|
||||||
connection: await this.getConnectionInfo(provider.providerId),
|
connection: await this.getConnectionInfo(provider.providerId),
|
||||||
|
|
@ -713,6 +720,13 @@ export class ProviderConnectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
options.hydrateModelCatalog === false &&
|
||||||
|
!isUsableCodexModelCatalog(withConnection.modelCatalog)
|
||||||
|
) {
|
||||||
|
return withConnection;
|
||||||
|
}
|
||||||
|
|
||||||
const orchestratorCatalog = isUsableCodexModelCatalog(withConnection.modelCatalog)
|
const orchestratorCatalog = isUsableCodexModelCatalog(withConnection.modelCatalog)
|
||||||
? withConnection.modelCatalog
|
? withConnection.modelCatalog
|
||||||
: null;
|
: null;
|
||||||
|
|
|
||||||
|
|
@ -926,6 +926,9 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed,
|
bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed,
|
||||||
hardFailure: skippedForLaunch ? false : entry.hardFailure,
|
hardFailure: skippedForLaunch ? false : entry.hardFailure,
|
||||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
||||||
|
bootstrapEvidenceSource: entry.bootstrapEvidenceSource,
|
||||||
|
bootstrapMode: entry.bootstrapMode,
|
||||||
|
appManagedBootstrapCandidate: entry.appManagedBootstrapCandidate,
|
||||||
livenessKind: entry.livenessKind,
|
livenessKind: entry.livenessKind,
|
||||||
runtimeDiagnostic: entry.runtimeDiagnostic,
|
runtimeDiagnostic: entry.runtimeDiagnostic,
|
||||||
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
|
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
|
||||||
|
|
|
||||||
|
|
@ -438,16 +438,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
|
|
||||||
const memberKeysWithRecentErrors = new Set<string>();
|
const memberKeysWithRecentErrors = new Set<string>();
|
||||||
for (const [memberKey, records] of recordsByMember) {
|
for (const [memberKey, records] of recordsByMember) {
|
||||||
if (
|
if (records.some((record) => this.isOpenCodeDeliveryAdvisoryCandidate(record, now))) {
|
||||||
records.some((record) => {
|
|
||||||
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
|
|
||||||
return (
|
|
||||||
isPotentialOpenCodeRuntimeDeliveryError(record) &&
|
|
||||||
Number.isFinite(observedAt) &&
|
|
||||||
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
memberKeysWithRecentErrors.add(memberKey);
|
memberKeysWithRecentErrors.add(memberKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -509,12 +500,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
);
|
);
|
||||||
const latestSuccess = ordered.find(isTerminalSuccessfulOpenCodeDeliveryRecord);
|
const latestSuccess = ordered.find(isTerminalSuccessfulOpenCodeDeliveryRecord);
|
||||||
const latestError = ordered.find((record) => {
|
const latestError = ordered.find((record) => {
|
||||||
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
|
return this.isOpenCodeDeliveryAdvisoryCandidate(record, now);
|
||||||
return (
|
|
||||||
isPotentialOpenCodeRuntimeDeliveryError(record) &&
|
|
||||||
Number.isFinite(observedAt) &&
|
|
||||||
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
if (!latestError) {
|
if (!latestError) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -540,14 +526,87 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
if (!message || !decision.observedAt) {
|
if (!message || !decision.observedAt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const retryWindow = this.extractOpenCodeDeliveryRetryWindow(latestError, now);
|
||||||
return {
|
return {
|
||||||
kind: 'api_error',
|
kind: 'api_error',
|
||||||
observedAt: decision.observedAt,
|
observedAt: decision.observedAt,
|
||||||
reasonCode: decision.reasonCode,
|
reasonCode: decision.reasonCode,
|
||||||
message,
|
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(
|
private async findRecentMemberAdvisoriesFromBatchRefs(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNames: readonly string[]
|
memberNames: readonly string[]
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
import {
|
import {
|
||||||
listRuntimeProcessesForCurrentTmuxPlatform,
|
listRuntimeProcessTableForCurrentPlatform,
|
||||||
type RuntimeProcessTableRow,
|
type RuntimeProcessTableRow,
|
||||||
} from '@features/tmux-installer/main';
|
} from '@features/tmux-installer/main';
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
|
|
@ -62,7 +62,7 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
||||||
|
|
||||||
const rows = await (
|
const rows = await (
|
||||||
options.listProcessRows ??
|
options.listProcessRows ??
|
||||||
(platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessesForCurrentTmuxPlatform)
|
(platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessTableForCurrentPlatform)
|
||||||
)();
|
)();
|
||||||
const excludePids = options.excludePids ?? new Set<number>();
|
const excludePids = options.excludePids ?? new Set<number>();
|
||||||
const requiredDetailsMarkers = options.requiredDetailsMarkers ?? [];
|
const requiredDetailsMarkers = options.requiredDetailsMarkers ?? [];
|
||||||
|
|
@ -99,7 +99,8 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
||||||
const isManagedByWindowsCommand =
|
const isManagedByWindowsCommand =
|
||||||
platform === 'win32' && isAppManagedWindowsOpenCodeServeCommand(row.command);
|
platform === 'win32' && isAppManagedWindowsOpenCodeServeCommand(row.command);
|
||||||
const isManaged =
|
const isManaged =
|
||||||
isManagedByWindowsCommand || Boolean(details && isManagedOpenCodeServeProcessDetails(details));
|
isManagedByWindowsCommand ||
|
||||||
|
Boolean(details && isManagedOpenCodeServeProcessDetails(details));
|
||||||
const hasRequiredDetailsMarkers =
|
const hasRequiredDetailsMarkers =
|
||||||
requiredDetailsMarkers.length === 0 ||
|
requiredDetailsMarkers.length === 0 ||
|
||||||
Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers));
|
Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers));
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,15 @@ export function isTerminalSuccessfulOpenCodeDeliveryRecord(
|
||||||
export function isPotentialOpenCodeRuntimeDeliveryError(
|
export function isPotentialOpenCodeRuntimeDeliveryError(
|
||||||
record: OpenCodePromptDeliveryLedgerRecord
|
record: OpenCodePromptDeliveryLedgerRecord
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const terminalSuccess =
|
||||||
|
record.status === 'responded' &&
|
||||||
|
Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId);
|
||||||
|
if (
|
||||||
|
!terminalSuccess &&
|
||||||
|
isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (record.status === 'failed_terminal') {
|
if (record.status === 'failed_terminal') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boo
|
||||||
export function selectOpenCodeRuntimeDeliveryReason(
|
export function selectOpenCodeRuntimeDeliveryReason(
|
||||||
record: OpenCodePromptDeliveryLedgerRecord
|
record: OpenCodePromptDeliveryLedgerRecord
|
||||||
): string | null {
|
): 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);
|
const selected = selectRuntimeDiagnosticClassification(candidates);
|
||||||
|
|
||||||
if (selected && !selected.generic && selected.normalizedMessage) {
|
if (selected && !selected.generic && selected.normalizedMessage) {
|
||||||
|
|
@ -33,6 +35,19 @@ export function selectOpenCodeRuntimeDeliveryReason(
|
||||||
return selected ? 'OpenCode runtime delivery did not complete.' : null;
|
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(
|
export function isActionRequiredOpenCodeRuntimeDeliveryReason(
|
||||||
message: string | null | undefined
|
message: string | null | undefined
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,12 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
|
||||||
'capacity exceeded',
|
'capacity exceeded',
|
||||||
'quota exceeded',
|
'quota exceeded',
|
||||||
'quota exhausted',
|
'quota exhausted',
|
||||||
|
'usage exceeded',
|
||||||
|
'free usage exceeded',
|
||||||
'insufficient credits',
|
'insufficient credits',
|
||||||
'key limit exceeded',
|
'key limit exceeded',
|
||||||
'total limit',
|
'total limit',
|
||||||
|
'subscribe to go',
|
||||||
],
|
],
|
||||||
priority: 95,
|
priority: 95,
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,13 @@ export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
|
||||||
/** Main -> renderer startup progress update */
|
/** Main -> renderer startup progress update */
|
||||||
export const APP_STARTUP_PROGRESS = 'appStartup:progress';
|
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
|
// Config API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,7 @@ import {
|
||||||
TEAM_UPDATE_TASK_OWNER,
|
TEAM_UPDATE_TASK_OWNER,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
TEAM_VALIDATE_CLI_ARGS,
|
TEAM_VALIDATE_CLI_ARGS,
|
||||||
|
TELEMETRY_GET_SENTRY_CONTEXT,
|
||||||
TERMINAL_DATA,
|
TERMINAL_DATA,
|
||||||
TERMINAL_EXIT,
|
TERMINAL_EXIT,
|
||||||
TERMINAL_KILL,
|
TERMINAL_KILL,
|
||||||
|
|
@ -495,6 +496,9 @@ const electronAPI: ElectronAPI = {
|
||||||
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
||||||
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
||||||
memberLogStream: createMemberLogStreamBridge(),
|
memberLogStream: createMemberLogStreamBridge(),
|
||||||
|
telemetry: {
|
||||||
|
getSentryContext: () => ipcRenderer.invoke(TELEMETRY_GET_SENTRY_CONTEXT),
|
||||||
|
},
|
||||||
startup: {
|
startup: {
|
||||||
getStatus: () => ipcRenderer.invoke(APP_STARTUP_GET_STATUS) as Promise<AppStartupStatus>,
|
getStatus: () => ipcRenderer.invoke(APP_STARTUP_GET_STATUS) as Promise<AppStartupStatus>,
|
||||||
onProgress: (callback: (status: AppStartupStatus) => void): (() => void) => {
|
onProgress: (callback: (status: AppStartupStatus) => void): (() => void) => {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
private eventSource: EventSource | null = null;
|
private eventSource: EventSource | null = null;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
|
||||||
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
|
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||||
|
telemetry = {
|
||||||
|
getSentryContext: async () => null,
|
||||||
|
};
|
||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
|
|
||||||
|
|
@ -855,6 +855,9 @@ const InstalledBanner = ({
|
||||||
isProviderCardLoading(provider, providerLoading) ||
|
isProviderCardLoading(provider, providerLoading) ||
|
||||||
isCodexSnapshotPending(provider, codexSnapshotPending) ||
|
isCodexSnapshotPending(provider, codexSnapshotPending) ||
|
||||||
maskNegativeBootstrapState;
|
maskNegativeBootstrapState;
|
||||||
|
const anthropicRateLimitsLoading =
|
||||||
|
provider.providerId === 'anthropic' &&
|
||||||
|
(anthropicRateLimitsRefreshing || provider.modelCatalogRefreshState === 'loading');
|
||||||
const showRateLimitSkeleton =
|
const showRateLimitSkeleton =
|
||||||
(showSkeleton &&
|
(showSkeleton &&
|
||||||
shouldShowDashboardRateLimitSkeleton({
|
shouldShowDashboardRateLimitSkeleton({
|
||||||
|
|
@ -865,8 +868,9 @@ const InstalledBanner = ({
|
||||||
(isSubscriptionRateLimitMode &&
|
(isSubscriptionRateLimitMode &&
|
||||||
!hasDashboardRateLimits &&
|
!hasDashboardRateLimits &&
|
||||||
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
|
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
|
||||||
(provider.providerId === 'anthropic' && anthropicRateLimitsRefreshing)));
|
anthropicRateLimitsLoading));
|
||||||
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
|
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
|
||||||
|
const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading';
|
||||||
const hasDetailContent = Boolean(
|
const hasDetailContent = Boolean(
|
||||||
(provider.backend?.label && !runtimeSummary) ||
|
(provider.backend?.label && !runtimeSummary) ||
|
||||||
runtimeSummary ||
|
runtimeSummary ||
|
||||||
|
|
@ -934,7 +938,10 @@ const InstalledBanner = ({
|
||||||
) : null}
|
) : null}
|
||||||
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
|
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
|
||||||
{credentialSummary ? <span>{credentialSummary}</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>
|
<span>Models unavailable for this runtime build</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import { formatBytes } from '@renderer/utils/formatters';
|
import { formatBytes } from '@renderer/utils/formatters';
|
||||||
|
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||||
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||||
|
|
@ -249,10 +250,17 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
: loadingCliStatus,
|
: loadingCliStatus,
|
||||||
[codexAccount.snapshot, loadingCliStatus]
|
[codexAccount.snapshot, loadingCliStatus]
|
||||||
);
|
);
|
||||||
|
const visibleEffectiveProviders = useMemo(
|
||||||
|
() => filterMainScreenCliProviders(effectiveCliStatus?.providers ?? []),
|
||||||
|
[effectiveCliStatus?.providers]
|
||||||
|
);
|
||||||
const loadingCliProviderMap = useMemo(
|
const loadingCliProviderMap = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
(loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider])
|
filterMainScreenCliProviders(loadingCliStatus?.providers ?? []).map((provider) => [
|
||||||
|
provider.providerId,
|
||||||
|
provider,
|
||||||
|
])
|
||||||
),
|
),
|
||||||
[loadingCliStatus?.providers]
|
[loadingCliStatus?.providers]
|
||||||
);
|
);
|
||||||
|
|
@ -485,9 +493,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{effectiveCliStatus.providers.length > 0 && (
|
{visibleEffectiveProviders.length > 0 && (
|
||||||
<div className="ml-6 mt-3 space-y-2">
|
<div className="ml-6 mt-3 space-y-2">
|
||||||
{effectiveCliStatus.providers.map((provider) => (
|
{visibleEffectiveProviders.map((provider) => (
|
||||||
<div
|
<div
|
||||||
key={provider.providerId}
|
key={provider.providerId}
|
||||||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
|
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
|
const statusText = effectiveShowSkeleton
|
||||||
? 'Checking...'
|
? 'Checking...'
|
||||||
: formatProviderStatusText(provider);
|
: formatProviderStatusText(provider);
|
||||||
|
const modelCatalogLoading =
|
||||||
|
provider.modelCatalogRefreshState === 'loading';
|
||||||
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
||||||
const credentialSummary = getProviderCredentialSummary(provider);
|
const credentialSummary = getProviderCredentialSummary(provider);
|
||||||
const disconnectAction = getProviderDisconnectAction(provider);
|
const disconnectAction = getProviderDisconnectAction(provider);
|
||||||
|
|
@ -575,7 +585,10 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
<span>{connectionModeSummary}</span>
|
<span>{connectionModeSummary}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{credentialSummary ? <span>{credentialSummary}</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>
|
<span>Models unavailable for this runtime build</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
|
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
|
||||||
|
|
@ -7,6 +8,7 @@ import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { markTaskUnread } from '@renderer/services/commentReadStorage';
|
import { markTaskUnread } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,6 +18,7 @@ import {
|
||||||
NO_PROJECT_KEY,
|
NO_PROJECT_KEY,
|
||||||
sortTasksByFreshness,
|
sortTasksByFreshness,
|
||||||
} from '@renderer/utils/taskGrouping';
|
} from '@renderer/utils/taskGrouping';
|
||||||
|
import { resolveTeamStatus } from '@renderer/utils/teamListStatus';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
|
@ -191,6 +194,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
viewMode,
|
viewMode,
|
||||||
repositoryGroups,
|
repositoryGroups,
|
||||||
teams,
|
teams,
|
||||||
|
provisioningRuns,
|
||||||
|
currentProvisioningRunIdByTeam,
|
||||||
|
leadActivityByTeam,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
globalTasks: s.globalTasks,
|
globalTasks: s.globalTasks,
|
||||||
|
|
@ -202,6 +208,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
viewMode: s.viewMode,
|
viewMode: s.viewMode,
|
||||||
repositoryGroups: s.repositoryGroups,
|
repositoryGroups: s.repositoryGroups,
|
||||||
teams: s.teams,
|
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 [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
||||||
|
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||||
|
const [aliveTeamsInitialized, setAliveTeamsInitialized] = useState(false);
|
||||||
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
|
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
|
||||||
Record<string, number>
|
Record<string, number>
|
||||||
>({});
|
>({});
|
||||||
|
|
@ -224,6 +235,21 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
const hasFetchedRef = useRef(false);
|
const hasFetchedRef = useRef(false);
|
||||||
const readState = useReadStateSnapshot();
|
const readState = useReadStateSnapshot();
|
||||||
const taskLocalState = useTaskLocalState();
|
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) ---
|
// --- New-task animation tracking (same pattern as ChatHistory) ---
|
||||||
const knownTaskIdsRef = useRef<Set<string>>(new Set());
|
const knownTaskIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
@ -262,6 +288,70 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
[newTaskIds]
|
[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 => {
|
const setGroupingMode = (mode: TaskGroupingMode): void => {
|
||||||
setGroupingModeState(mode);
|
setGroupingModeState(mode);
|
||||||
saveGroupingMode(mode);
|
saveGroupingMode(mode);
|
||||||
|
|
@ -561,6 +651,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
showTeamName
|
showTeamName
|
||||||
|
teamOffline={offlineTeamNames.has(task.teamName)}
|
||||||
renamingKey={renamingTaskKey}
|
renamingKey={renamingTaskKey}
|
||||||
onRenameComplete={handleRenameComplete}
|
onRenameComplete={handleRenameComplete}
|
||||||
onRenameCancel={handleRenameCancel}
|
onRenameCancel={handleRenameCancel}
|
||||||
|
|
@ -655,6 +746,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
showTeamName
|
showTeamName
|
||||||
|
teamOffline={offlineTeamNames.has(task.teamName)}
|
||||||
renamingKey={renamingTaskKey}
|
renamingKey={renamingTaskKey}
|
||||||
onRenameComplete={handleRenameComplete}
|
onRenameComplete={handleRenameComplete}
|
||||||
onRenameCancel={handleRenameCancel}
|
onRenameCancel={handleRenameCancel}
|
||||||
|
|
@ -742,6 +834,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
task={task}
|
task={task}
|
||||||
hideTeamName
|
hideTeamName
|
||||||
hideProjectName
|
hideProjectName
|
||||||
|
teamOffline={offlineTeamNames.has(task.teamName)}
|
||||||
renamingKey={renamingTaskKey}
|
renamingKey={renamingTaskKey}
|
||||||
onRenameComplete={handleRenameComplete}
|
onRenameComplete={handleRenameComplete}
|
||||||
onRenameCancel={handleRenameCancel}
|
onRenameCancel={handleRenameCancel}
|
||||||
|
|
@ -848,6 +941,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
<AnimatedHeightReveal animate={isNewTask(task)}>
|
<AnimatedHeightReveal animate={isNewTask(task)}>
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
|
teamOffline={offlineTeamNames.has(task.teamName)}
|
||||||
renamingKey={renamingTaskKey}
|
renamingKey={renamingTaskKey}
|
||||||
onRenameComplete={handleRenameComplete}
|
onRenameComplete={handleRenameComplete}
|
||||||
onRenameCancel={handleRenameCancel}
|
onRenameCancel={handleRenameCancel}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ interface SidebarTaskItemProps {
|
||||||
hideTeamName?: boolean;
|
hideTeamName?: boolean;
|
||||||
hideProjectName?: boolean;
|
hideProjectName?: boolean;
|
||||||
showTeamName?: 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 */
|
/** The composite key "teamName:taskId" of the task being renamed, or null */
|
||||||
renamingKey?: string | null;
|
renamingKey?: string | null;
|
||||||
/** Called when rename is completed with Enter or blur */
|
/** Called when rename is completed with Enter or blur */
|
||||||
|
|
@ -80,6 +82,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
hideTeamName,
|
hideTeamName,
|
||||||
hideProjectName,
|
hideProjectName,
|
||||||
showTeamName,
|
showTeamName,
|
||||||
|
teamOffline = false,
|
||||||
renamingKey,
|
renamingKey,
|
||||||
onRenameComplete,
|
onRenameComplete,
|
||||||
onRenameCancel,
|
onRenameCancel,
|
||||||
|
|
@ -120,10 +123,11 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
||||||
: (statusConfig[task.status] ?? statusConfig.pending);
|
: (statusConfig[task.status] ?? statusConfig.pending);
|
||||||
const StatusIcon = cfg.icon;
|
const StatusIcon = cfg.icon;
|
||||||
|
const shouldAnimateStatusIcon = cfg.label === 'in progress' && !teamOffline;
|
||||||
const statusIconClassName = cn(
|
const statusIconClassName = cn(
|
||||||
'size-3 shrink-0',
|
'size-3 shrink-0',
|
||||||
cfg.color,
|
cfg.color,
|
||||||
cfg.label === 'in progress' && 'animate-spin'
|
shouldAnimateStatusIcon && 'animate-spin'
|
||||||
);
|
);
|
||||||
const updatedLabel = formatUpdatedLabel(task);
|
const updatedLabel = formatUpdatedLabel(task);
|
||||||
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ interface RenderedTeamChangeSummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
|
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[] {
|
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
|
||||||
if (!Array.isArray(changeSet?.files)) {
|
if (!Array.isArray(changeSet?.files)) {
|
||||||
|
|
@ -111,6 +115,23 @@ function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefi
|
||||||
return undefined;
|
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[] {
|
function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
||||||
const status = classifyTaskChangeReviewability(changeSet);
|
const status = classifyTaskChangeReviewability(changeSet);
|
||||||
if (status.reviewability === 'unknown' || status.reviewability === 'none') {
|
if (status.reviewability === 'unknown' || status.reviewability === 'none') {
|
||||||
|
|
@ -120,7 +141,13 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
||||||
status.diagnostics.length > 0
|
status.diagnostics.length > 0
|
||||||
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||||
: getChangeSetWarnings(changeSet);
|
: 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({
|
export const TeamChangesSection = memo(function TeamChangesSection({
|
||||||
|
|
|
||||||
|
|
@ -1397,6 +1397,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
});
|
});
|
||||||
const [creatingTask, setCreatingTask] = useState(false);
|
const [creatingTask, setCreatingTask] = useState(false);
|
||||||
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
||||||
|
const [runtimeTelemetryPreviewVisible, setRuntimeTelemetryPreviewVisible] = useState(false);
|
||||||
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
||||||
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
||||||
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
|
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
|
||||||
|
|
@ -2877,20 +2878,35 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
icon={<Users size={14} />}
|
icon={<Users size={14} />}
|
||||||
badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
|
badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
|
||||||
defaultOpen
|
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={
|
action={
|
||||||
<div className="flex items-center gap-1">
|
<div
|
||||||
<Button
|
className={cn(
|
||||||
variant="ghost"
|
'flex items-center gap-3 pr-3 text-[11px] font-medium leading-none text-[var(--color-text-muted)] transition-opacity duration-150',
|
||||||
size="sm"
|
runtimeTelemetryPreviewVisible ? 'opacity-100' : 'opacity-0'
|
||||||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
)}
|
||||||
onClick={(e) => {
|
>
|
||||||
e.stopPropagation();
|
<span className="flex items-center gap-1.5">
|
||||||
setAddMemberDialogOpen(true);
|
<span className="size-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(34,197,94,0.3)]" />
|
||||||
}}
|
Memory
|
||||||
>
|
</span>
|
||||||
<UserPlus size={12} />
|
<span className="flex items-center gap-1.5">
|
||||||
Member
|
<span className="size-2 rounded-full bg-blue-500 shadow-[0_0_6px_rgba(59,130,246,0.3)]" />
|
||||||
</Button>
|
CPU
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
contentWrapperClassName="-mx-[calc(1rem-5px)] w-[calc(100%+2rem-10px)]"
|
contentWrapperClassName="-mx-[calc(1rem-5px)] w-[calc(100%+2rem-10px)]"
|
||||||
|
|
@ -2907,6 +2923,8 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
launchParams={launchParams}
|
launchParams={launchParams}
|
||||||
|
runtimeTelemetryVisible={runtimeTelemetryPreviewVisible}
|
||||||
|
onRuntimeTelemetryHoverChange={setRuntimeTelemetryPreviewVisible}
|
||||||
onMemberClick={handleSelectMember}
|
onMemberClick={handleSelectMember}
|
||||||
onSendMessage={handleSendMessageToMember}
|
onSendMessage={handleSendMessageToMember}
|
||||||
onAssignTask={handleAssignTaskToMember}
|
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 {
|
function invalidFileSummaryResponse(): TeamTaskChangeSummariesResponse {
|
||||||
return response({
|
return response({
|
||||||
...changeSet(),
|
...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 () => {
|
it('does not clear completed task presence from an uncertain empty summary', async () => {
|
||||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ type ProvisioningDetailSummary =
|
||||||
| 'Selected model unavailable'
|
| 'Selected model unavailable'
|
||||||
| 'Selected model verification timed out'
|
| 'Selected model verification timed out'
|
||||||
| 'Selected model check failed'
|
| 'Selected model check failed'
|
||||||
|
| 'Selected model verification deferred'
|
||||||
| 'Selected model ping not confirmed'
|
| 'Selected model ping not confirmed'
|
||||||
| 'Ready with notes'
|
| 'Ready with notes'
|
||||||
| 'Needs attention';
|
| 'Needs attention';
|
||||||
|
|
@ -163,6 +164,7 @@ function isFormattedModelDetail(lower: string): boolean {
|
||||||
lower.includes(' - compatible, deep verification pending') ||
|
lower.includes(' - compatible, deep verification pending') ||
|
||||||
lower.includes(' - unavailable') ||
|
lower.includes(' - unavailable') ||
|
||||||
lower.includes(' - check failed') ||
|
lower.includes(' - check failed') ||
|
||||||
|
lower.includes(' - verification deferred') ||
|
||||||
lower.includes(' - ping not confirmed')
|
lower.includes(' - ping not confirmed')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +246,9 @@ function summarizeDetail(
|
||||||
if (isSelectedModelDetail(lower) && lower.includes('could not be verified')) {
|
if (isSelectedModelDetail(lower) && lower.includes('could not be verified')) {
|
||||||
return 'Selected model check failed';
|
return 'Selected model check failed';
|
||||||
}
|
}
|
||||||
|
if (isSelectedModelDetail(lower) && lower.includes('verification deferred')) {
|
||||||
|
return 'Selected model verification deferred';
|
||||||
|
}
|
||||||
if (lower.includes(' - verified')) {
|
if (lower.includes(' - verified')) {
|
||||||
return 'Selected model verified';
|
return 'Selected model verified';
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +264,9 @@ function summarizeDetail(
|
||||||
if (lower.includes(' - check failed -')) {
|
if (lower.includes(' - check failed -')) {
|
||||||
return 'Selected model check failed';
|
return 'Selected model check failed';
|
||||||
}
|
}
|
||||||
|
if (lower.includes(' - verification deferred')) {
|
||||||
|
return 'Selected model verification deferred';
|
||||||
|
}
|
||||||
if (lower.includes(' - ping not confirmed')) {
|
if (lower.includes(' - ping not confirmed')) {
|
||||||
return 'Selected model ping not confirmed';
|
return 'Selected model ping not confirmed';
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +287,7 @@ function getModelDetailSummary(details: string[]): string | null {
|
||||||
let unavailableCount = 0;
|
let unavailableCount = 0;
|
||||||
let timedOutCount = 0;
|
let timedOutCount = 0;
|
||||||
let checkFailedCount = 0;
|
let checkFailedCount = 0;
|
||||||
|
let deferredCount = 0;
|
||||||
let pingNotConfirmedCount = 0;
|
let pingNotConfirmedCount = 0;
|
||||||
let checkingCount = 0;
|
let checkingCount = 0;
|
||||||
|
|
||||||
|
|
@ -327,6 +336,13 @@ function getModelDetailSummary(details: string[]): string | null {
|
||||||
checkFailedCount += 1;
|
checkFailedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
lower.includes(' - verification deferred') ||
|
||||||
|
(isSelectedModelDetail(lower) && lower.includes('verification deferred'))
|
||||||
|
) {
|
||||||
|
deferredCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (lower.includes(' - ping not confirmed')) {
|
if (lower.includes(' - ping not confirmed')) {
|
||||||
pingNotConfirmedCount += 1;
|
pingNotConfirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -346,6 +362,9 @@ function getModelDetailSummary(details: string[]): string | null {
|
||||||
if (timedOutCount > 0) {
|
if (timedOutCount > 0) {
|
||||||
parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
|
parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
|
||||||
}
|
}
|
||||||
|
if (deferredCount > 0) {
|
||||||
|
parts.push(`${deferredCount} verification deferred`);
|
||||||
|
}
|
||||||
if (pingNotConfirmedCount > 0) {
|
if (pingNotConfirmedCount > 0) {
|
||||||
parts.push(`${pingNotConfirmedCount} ping not confirmed`);
|
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 {
|
export interface TeamModelSelectorProps {
|
||||||
providerId: TeamProviderId;
|
providerId: TeamProviderId;
|
||||||
onProviderChange: (providerId: TeamProviderId) => void;
|
onProviderChange: (providerId: TeamProviderId) => void;
|
||||||
|
|
@ -957,11 +999,18 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
const visibleConcreteModelOptionCount =
|
const visibleConcreteModelOptionCount =
|
||||||
visibleModelOptions.length - visibleDefaultModelOptions.length;
|
visibleModelOptions.length - visibleDefaultModelOptions.length;
|
||||||
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).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 trimmedModelQuery = modelQuery.trim();
|
||||||
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
|
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
|
||||||
const shouldVirtualizeOpenCodeModels =
|
const shouldVirtualizeOpenCodeModels =
|
||||||
effectiveProviderId === 'opencode' &&
|
effectiveProviderId === 'opencode' &&
|
||||||
|
!shouldShowOpenCodeCatalogLoading &&
|
||||||
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
|
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
|
||||||
const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
|
const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
|
||||||
reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
|
reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
|
||||||
|
|
@ -1270,8 +1319,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{(effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
|
{!shouldShowOpenCodeCatalogLoading &&
|
||||||
hasRecommendedOpenCodeModels ? (
|
((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
|
||||||
|
hasRecommendedOpenCodeModels) ? (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
|
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
|
||||||
<Popover
|
<Popover
|
||||||
|
|
@ -1370,7 +1420,22 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{effectiveProviderId === 'opencode' ? (
|
{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
|
<OpenCodeVirtualizedModelGrid
|
||||||
defaultOptions={visibleDefaultModelOptions}
|
defaultOptions={visibleDefaultModelOptions}
|
||||||
groups={visibleOpenCodeModelGroups}
|
groups={visibleOpenCodeModelGroups}
|
||||||
|
|
@ -1437,7 +1502,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
{visibleModelOptions.map(renderModelOption)}
|
{visibleModelOptions.map(renderModelOption)}
|
||||||
</div>
|
</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)]">
|
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
{trimmedModelQuery
|
{trimmedModelQuery
|
||||||
? 'No models match this search.'
|
? 'No models match this search.'
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ function stripSelectedModelPrefix(modelId: string, message: string): string {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} is unavailable\\.\\s*`, 'i'),
|
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)} 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)} verified for launch\\.\\s*`, 'i'),
|
||||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} is available for launch\\.\\s*`, 'i'),
|
new RegExp(`^Selected model ${escapeRegExp(modelId)} is available for launch\\.\\s*`, 'i'),
|
||||||
new RegExp(
|
new RegExp(
|
||||||
|
|
@ -420,6 +421,17 @@ function buildModelFailureLine(
|
||||||
return reason ? `${label} - ${kind} - ${reason}` : `${label} - ${kind}`;
|
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[] {
|
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
|
||||||
return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]);
|
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) =>
|
const hasUnavailableLine = modelScopedEntries.some((entry) =>
|
||||||
/selected model .* is unavailable\./i.test(entry)
|
/selected model .* is unavailable\./i.test(entry)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,11 @@ export const CurrentTaskIndicator = memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
<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>
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
||||||
<button
|
<button
|
||||||
type="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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||||
|
|
@ -26,7 +26,20 @@ import {
|
||||||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
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 { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
|
|
@ -46,6 +59,11 @@ import type {
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
export interface RuntimeTelemetryScale {
|
||||||
|
memoryCapBytes?: number;
|
||||||
|
cpuCapPercent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface MemberCardProps {
|
interface MemberCardProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
memberColor: string;
|
memberColor: string;
|
||||||
|
|
@ -72,6 +90,8 @@ interface MemberCardProps {
|
||||||
spawnLaunchState?: MemberLaunchState;
|
spawnLaunchState?: MemberLaunchState;
|
||||||
spawnRuntimeAlive?: boolean;
|
spawnRuntimeAlive?: boolean;
|
||||||
isLaunchSettling?: boolean;
|
isLaunchSettling?: boolean;
|
||||||
|
runtimeTelemetryVisible?: boolean;
|
||||||
|
runtimeTelemetryScale?: RuntimeTelemetryScale;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
onOpenReviewTask?: () => void;
|
onOpenReviewTask?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|
@ -170,6 +190,232 @@ function buildAreaPath(points: readonly TelemetryPoint[]): string | undefined {
|
||||||
].join(' ');
|
].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(
|
function buildTelemetryPoints(
|
||||||
samples: readonly TeamAgentRuntimeResourceSample[],
|
samples: readonly TeamAgentRuntimeResourceSample[],
|
||||||
getValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined,
|
getValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined,
|
||||||
|
|
@ -194,9 +440,10 @@ function buildTelemetryPoints(
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRuntimeTelemetryPaths(
|
function buildRuntimeTelemetryPaths(
|
||||||
history: readonly TeamAgentRuntimeResourceSample[] | undefined
|
history: readonly TeamAgentRuntimeResourceSample[] | undefined,
|
||||||
|
scale?: RuntimeTelemetryScale
|
||||||
): RuntimeTelemetryPaths | undefined {
|
): RuntimeTelemetryPaths | undefined {
|
||||||
const samples = (history ?? []).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT);
|
const samples = normalizeRuntimeTelemetrySamples(history).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT);
|
||||||
if (samples.length < 2) {
|
if (samples.length < 2) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -205,19 +452,38 @@ function buildRuntimeTelemetryPaths(
|
||||||
samples,
|
samples,
|
||||||
(sample) => sample.rssBytes,
|
(sample) => sample.rssBytes,
|
||||||
(value, values) => {
|
(value, values) => {
|
||||||
const min = Math.min(...values);
|
const cappedY = getCappedTelemetryY(value, scale?.memoryCapBytes, {
|
||||||
const max = Math.max(...values);
|
bottomY: 15.25,
|
||||||
const ratio = max > min ? (value - min) / (max - min) : 0.32;
|
amplitude: 4.4,
|
||||||
return 15.25 - ratio * 4.4;
|
});
|
||||||
|
return (
|
||||||
|
cappedY ??
|
||||||
|
getRelativeTelemetryY(value, values, {
|
||||||
|
bottomY: 15.25,
|
||||||
|
amplitude: 4.4,
|
||||||
|
fallbackRatio: 0.32,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const cpuPoints = buildTelemetryPoints(
|
const cpuPoints = buildTelemetryPoints(
|
||||||
samples,
|
samples,
|
||||||
(sample) => sample.cpuPercent,
|
(sample) => sample.cpuPercent,
|
||||||
(value, values) => {
|
(value, values) => {
|
||||||
const max = Math.max(10, ...values);
|
const cappedY = getCappedTelemetryY(value, scale?.cpuCapPercent, {
|
||||||
const ratio = Math.min(1, value / max);
|
bottomY: 16.1,
|
||||||
return 8.3 - ratio * 4.6;
|
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({
|
const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
|
visible,
|
||||||
|
scale,
|
||||||
}: {
|
}: {
|
||||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
visible: boolean;
|
||||||
|
scale?: RuntimeTelemetryScale;
|
||||||
}): React.JSX.Element | null {
|
}): React.JSX.Element | null {
|
||||||
const paths = useMemo(
|
const paths = useMemo(
|
||||||
() => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory),
|
() => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory, scale),
|
||||||
[runtimeEntry?.resourceHistory]
|
[runtimeEntry?.resourceHistory, scale]
|
||||||
);
|
);
|
||||||
if (!paths) {
|
if (!paths) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -251,7 +521,16 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-testid="member-runtime-telemetry-strip"
|
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
|
<svg
|
||||||
className="size-full"
|
className="size-full"
|
||||||
|
|
@ -259,7 +538,7 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
{paths.memoryAreaPath ? (
|
{paths.memoryAreaPath ? (
|
||||||
<path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.22" />
|
<path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.14" />
|
||||||
) : null}
|
) : null}
|
||||||
{paths.memoryLinePath ? (
|
{paths.memoryLinePath ? (
|
||||||
<path
|
<path
|
||||||
|
|
@ -269,7 +548,7 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="0.55"
|
strokeWidth="0.55"
|
||||||
opacity="0.68"
|
opacity="0.45"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{paths.cpuLinePath ? (
|
{paths.cpuLinePath ? (
|
||||||
|
|
@ -280,17 +559,19 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="0.62"
|
strokeWidth="0.62"
|
||||||
opacity="0.78"
|
opacity="0.62"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</svg>
|
</svg>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 bottom-0 h-2"
|
className="absolute inset-x-0 bottom-0 h-1.5"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
'linear-gradient(to top, color-mix(in srgb, var(--color-surface) 35%, transparent), transparent)',
|
'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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -321,6 +602,8 @@ export const MemberCard = memo(function MemberCard({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
|
runtimeTelemetryVisible = false,
|
||||||
|
runtimeTelemetryScale,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onOpenReviewTask,
|
onOpenReviewTask,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
@ -412,6 +695,47 @@ export const MemberCard = memo(function MemberCard({
|
||||||
: reviewTask
|
: reviewTask
|
||||||
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
|
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
|
||||||
: undefined;
|
: 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 =
|
const showStartingSkeleton =
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
presenceLabel === 'starting' &&
|
presenceLabel === 'starting' &&
|
||||||
|
|
@ -439,15 +763,21 @@ export const MemberCard = memo(function MemberCard({
|
||||||
teamName: selectedTeamName,
|
teamName: selectedTeamName,
|
||||||
runId: runtimeRunId,
|
runId: runtimeRunId,
|
||||||
memberName: member.name,
|
memberName: member.name,
|
||||||
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
launchState: spawnLaunchState,
|
launchState: spawnLaunchState,
|
||||||
livenessSource: spawnLivenessSource,
|
livenessSource: spawnLivenessSource,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
|
runtimeAdvisoryLabel,
|
||||||
|
runtimeAdvisoryTitle,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
member.name,
|
member,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
|
runtimeAdvisoryLabel,
|
||||||
|
runtimeAdvisoryTitle,
|
||||||
runtimeRunId,
|
runtimeRunId,
|
||||||
selectedTeamName,
|
selectedTeamName,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
|
|
@ -460,6 +790,11 @@ export const MemberCard = memo(function MemberCard({
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||||
|
const showRuntimeAdvisoryDiagnostics =
|
||||||
|
!isRemoved &&
|
||||||
|
Boolean(runtimeAdvisoryLabel) &&
|
||||||
|
runtimeAdvisoryTone === 'error' &&
|
||||||
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||||
const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
|
const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
|
||||||
const isSkippedLaunch =
|
const isSkippedLaunch =
|
||||||
spawnStatus === 'skipped' ||
|
spawnStatus === 'skipped' ||
|
||||||
|
|
@ -503,13 +838,26 @@ export const MemberCard = memo(function MemberCard({
|
||||||
!isFailedLaunch &&
|
!isFailedLaunch &&
|
||||||
!isSkippedLaunch &&
|
!isSkippedLaunch &&
|
||||||
(Boolean(activityTask) || !isAwaitingReply);
|
(Boolean(activityTask) || !isAwaitingReply);
|
||||||
const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate';
|
const canRelaunchRuntimeAdvisoryOpenCode =
|
||||||
const restartActionBusyLabel = canRelaunchOpenCode
|
Boolean(runtimeAdvisoryLabel) &&
|
||||||
? 'Relaunching OpenCode teammate'
|
runtimeAdvisoryTone === 'error' &&
|
||||||
: 'Retrying teammate';
|
member.providerId === 'opencode' &&
|
||||||
const restartActionErrorFallback = canRelaunchOpenCode
|
hasRestartMemberControl &&
|
||||||
? 'Failed to relaunch OpenCode teammate'
|
!showLaunchBadge &&
|
||||||
: 'Failed to retry teammate';
|
!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> => {
|
const handleRestartMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
@ -545,7 +893,7 @@ export const MemberCard = memo(function MemberCard({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const cardContent = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded transition-opacity duration-300',
|
'rounded transition-opacity duration-300',
|
||||||
|
|
@ -560,7 +908,7 @@ export const MemberCard = memo(function MemberCard({
|
||||||
rowSurfaceBleedClass
|
rowSurfaceBleedClass
|
||||||
)}
|
)}
|
||||||
style={undefined}
|
style={undefined}
|
||||||
title={activityTitle}
|
title={rowTitle}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onClick}
|
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="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 z-20 flex items-center gap-2.5">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
|
|
@ -662,6 +1016,39 @@ export const MemberCard = memo(function MemberCard({
|
||||||
>
|
>
|
||||||
{runtimeAdvisoryLabel ?? 'awaiting reply'}
|
{runtimeAdvisoryLabel ?? 'awaiting reply'}
|
||||||
</span>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -869,31 +1256,62 @@ export const MemberCard = memo(function MemberCard({
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
) : showRuntimeAdvisoryBadge ? (
|
) : showRuntimeAdvisoryBadge ? (
|
||||||
<Tooltip>
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<TooltipTrigger asChild>
|
||||||
<AlertTriangle
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
className={`size-3.5 shrink-0 ${
|
<AlertTriangle
|
||||||
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
|
className={`size-3.5 shrink-0 ${
|
||||||
}`}
|
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
|
||||||
/>
|
}`}
|
||||||
<Badge
|
/>
|
||||||
variant="secondary"
|
<Badge
|
||||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
|
variant="secondary"
|
||||||
runtimeAdvisoryTone === 'error'
|
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
|
||||||
? 'bg-red-500/15 text-red-300'
|
runtimeAdvisoryTone === 'error'
|
||||||
: 'bg-amber-500/15 text-amber-300'
|
? 'bg-red-500/15 text-red-300'
|
||||||
}`}
|
: 'bg-amber-500/15 text-amber-300'
|
||||||
title={runtimeAdvisoryTitle}
|
}`}
|
||||||
>
|
title={runtimeAdvisoryTitle}
|
||||||
{runtimeAdvisoryLabel}
|
>
|
||||||
</Badge>
|
{runtimeAdvisoryLabel}
|
||||||
</span>
|
</Badge>
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipContent side="bottom">
|
</TooltipTrigger>
|
||||||
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
|
<TooltipContent side="bottom">
|
||||||
</TooltipContent>
|
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
|
||||||
</Tooltip>
|
</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 ? (
|
) : !activityTask ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -977,4 +1395,26 @@ export const MemberCard = memo(function MemberCard({
|
||||||
</div>
|
</div>
|
||||||
</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 { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
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 { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||||
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
||||||
|
|
@ -25,6 +25,7 @@ import type {
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
|
TeamAgentRuntimeResourceSample,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -44,6 +45,8 @@ interface MemberListProps {
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
launchParams?: TeamLaunchParams;
|
launchParams?: TeamLaunchParams;
|
||||||
|
runtimeTelemetryVisible?: boolean;
|
||||||
|
onRuntimeTelemetryHoverChange?: (visible: boolean) => void;
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
onAssignTask?: (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(
|
function areMemberRuntimeEntriesEquivalent(
|
||||||
left: Map<string, TeamAgentRuntimeEntry> | undefined,
|
left: Map<string, TeamAgentRuntimeEntry> | undefined,
|
||||||
right: Map<string, TeamAgentRuntimeEntry> | undefined
|
right: Map<string, TeamAgentRuntimeEntry> | undefined
|
||||||
|
|
@ -273,10 +302,15 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
if (left.size !== right.size) return false;
|
if (left.size !== right.size) return false;
|
||||||
for (const [key, leftEntry] of left) {
|
for (const [key, leftEntry] of left) {
|
||||||
const rightEntry = right.get(key);
|
const rightEntry = right.get(key);
|
||||||
const leftDiagnostics = leftEntry.diagnostics ?? [];
|
const leftDiagnostics = Array.isArray(leftEntry.diagnostics) ? leftEntry.diagnostics : [];
|
||||||
const rightDiagnostics = rightEntry?.diagnostics ?? [];
|
const rightDiagnostics = Array.isArray(rightEntry?.diagnostics) ? rightEntry.diagnostics : [];
|
||||||
const leftResourceHistory = leftEntry.resourceHistory ?? [];
|
const rightResourceHistoryCandidate = rightEntry?.resourceHistory;
|
||||||
const rightResourceHistory = rightEntry?.resourceHistory ?? [];
|
const leftResourceHistory = Array.isArray(leftEntry.resourceHistory)
|
||||||
|
? leftEntry.resourceHistory
|
||||||
|
: [];
|
||||||
|
const rightResourceHistory = Array.isArray(rightResourceHistoryCandidate)
|
||||||
|
? rightResourceHistoryCandidate
|
||||||
|
: [];
|
||||||
if (
|
if (
|
||||||
leftEntry.memberName !== rightEntry?.memberName ||
|
leftEntry.memberName !== rightEntry?.memberName ||
|
||||||
leftEntry.alive !== rightEntry?.alive ||
|
leftEntry.alive !== rightEntry?.alive ||
|
||||||
|
|
@ -290,6 +324,13 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
||||||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
||||||
leftEntry.cpuPercent !== rightEntry?.cpuPercent ||
|
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.livenessKind !== rightEntry?.livenessKind ||
|
||||||
leftEntry.pidSource !== rightEntry?.pidSource ||
|
leftEntry.pidSource !== rightEntry?.pidSource ||
|
||||||
leftEntry.processCommand !== rightEntry?.processCommand ||
|
leftEntry.processCommand !== rightEntry?.processCommand ||
|
||||||
|
|
@ -305,17 +346,9 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
leftDiagnostics.length !== rightDiagnostics.length ||
|
leftDiagnostics.length !== rightDiagnostics.length ||
|
||||||
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ||
|
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ||
|
||||||
leftResourceHistory.length !== rightResourceHistory.length ||
|
leftResourceHistory.length !== rightResourceHistory.length ||
|
||||||
!leftResourceHistory.every((value, index) => {
|
!leftResourceHistory.every((value, index) =>
|
||||||
const other = rightResourceHistory[index];
|
areRuntimeResourceSamplesEquivalent(value, 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
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -323,6 +356,95 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
return true;
|
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(
|
function areMemberListPropsEqual(
|
||||||
prev: Readonly<MemberListProps>,
|
prev: Readonly<MemberListProps>,
|
||||||
next: Readonly<MemberListProps>
|
next: Readonly<MemberListProps>
|
||||||
|
|
@ -342,6 +464,8 @@ function areMemberListPropsEqual(
|
||||||
prev.isTeamAlive === next.isTeamAlive &&
|
prev.isTeamAlive === next.isTeamAlive &&
|
||||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||||
prev.leadActivity === next.leadActivity &&
|
prev.leadActivity === next.leadActivity &&
|
||||||
|
prev.runtimeTelemetryVisible === next.runtimeTelemetryVisible &&
|
||||||
|
prev.onRuntimeTelemetryHoverChange === next.onRuntimeTelemetryHoverChange &&
|
||||||
prev.onRestartMember === next.onRestartMember &&
|
prev.onRestartMember === next.onRestartMember &&
|
||||||
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
||||||
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
||||||
|
|
@ -378,6 +502,8 @@ interface MemberCardRowProps {
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
isLaunchSettling?: boolean;
|
isLaunchSettling?: boolean;
|
||||||
|
runtimeTelemetryVisible?: boolean;
|
||||||
|
runtimeTelemetryScale?: RuntimeTelemetryScale;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
|
|
@ -412,6 +538,8 @@ const MemberCardRow = memo(function MemberCardRow({
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
|
runtimeTelemetryVisible,
|
||||||
|
runtimeTelemetryScale,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
|
@ -461,6 +589,8 @@ const MemberCardRow = memo(function MemberCardRow({
|
||||||
spawnLaunchState={spawnLaunchState}
|
spawnLaunchState={spawnLaunchState}
|
||||||
spawnRuntimeAlive={spawnRuntimeAlive}
|
spawnRuntimeAlive={spawnRuntimeAlive}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
|
runtimeTelemetryVisible={runtimeTelemetryVisible}
|
||||||
|
runtimeTelemetryScale={runtimeTelemetryScale}
|
||||||
onOpenTask={currentTask ? handleOpenTask : undefined}
|
onOpenTask={currentTask ? handleOpenTask : undefined}
|
||||||
onOpenReviewTask={reviewTask ? handleOpenReviewTask : undefined}
|
onOpenReviewTask={reviewTask ? handleOpenReviewTask : undefined}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
@ -584,6 +714,8 @@ export const MemberList = memo(function MemberList({
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
launchParams,
|
launchParams,
|
||||||
|
runtimeTelemetryVisible = false,
|
||||||
|
onRuntimeTelemetryHoverChange,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -610,6 +742,14 @@ export const MemberList = memo(function MemberList({
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [handleResize]);
|
}, [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 gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
|
||||||
const activeMembers = useMemo(
|
const activeMembers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -628,6 +768,10 @@ export const MemberList = memo(function MemberList({
|
||||||
[activeMembers]
|
[activeMembers]
|
||||||
);
|
);
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
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.
|
// Pre-compute reviewer->task map to avoid O(n*n) scan per member.
|
||||||
const reviewTaskByMember = useMemo(() => {
|
const reviewTaskByMember = useMemo(() => {
|
||||||
const result = new Map<string, TeamTaskWithKanban>();
|
const result = new Map<string, TeamTaskWithKanban>();
|
||||||
|
|
@ -797,7 +941,12 @@ export const MemberList = memo(function MemberList({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}>
|
<div className={gridClass}>
|
||||||
{activeMembers.map((member) => {
|
{activeMembers.map((member) => {
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
|
|
@ -868,6 +1017,8 @@ export const MemberList = memo(function MemberList({
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
|
runtimeTelemetryVisible={runtimeTelemetryVisible}
|
||||||
|
runtimeTelemetryScale={runtimeTelemetryScale}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onMemberClick={onMemberClick}
|
onMemberClick={onMemberClick}
|
||||||
onSendMessage={onSendMessage}
|
onSendMessage={onSendMessage}
|
||||||
|
|
@ -912,6 +1063,8 @@ export const MemberList = memo(function MemberList({
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
isLaunchSettling={false}
|
isLaunchSettling={false}
|
||||||
|
runtimeTelemetryVisible={runtimeTelemetryVisible}
|
||||||
|
runtimeTelemetryScale={runtimeTelemetryScale}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onMemberClick={onMemberClick}
|
onMemberClick={onMemberClick}
|
||||||
onSendMessage={onSendMessage}
|
onSendMessage={onSendMessage}
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,9 @@ export function reconcilePendingRepliesByMember(
|
||||||
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
||||||
const latestReplyAt = latestReplyToUserByMember.get(memberName);
|
const latestReplyAt = latestReplyToUserByMember.get(memberName);
|
||||||
const latestDurableSendAt = latestUserSentByMember.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) {
|
if (latestReplyAt != null && latestReplyAt > threshold) {
|
||||||
changed = true;
|
changed = true;
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,21 @@ export const ScopeWarningBanner = ({
|
||||||
: 'Needs review',
|
: 'Needs review',
|
||||||
}
|
}
|
||||||
: null;
|
: 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;
|
const { Icon } = config;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,22 @@ const DEFAULT_SPIN_DURATION_MS = 1000;
|
||||||
|
|
||||||
export type SyncedLoader2Props = ComponentProps<typeof Loader2> & {
|
export type SyncedLoader2Props = ComponentProps<typeof Loader2> & {
|
||||||
spinDurationMs?: number;
|
spinDurationMs?: number;
|
||||||
|
spinning?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SyncedLoader2 = ({
|
export const SyncedLoader2 = ({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
spinDurationMs = DEFAULT_SPIN_DURATION_MS,
|
spinDurationMs = DEFAULT_SPIN_DURATION_MS,
|
||||||
|
spinning = true,
|
||||||
...props
|
...props
|
||||||
}: SyncedLoader2Props): React.JSX.Element => {
|
}: SyncedLoader2Props): React.JSX.Element => {
|
||||||
const syncedStyle = useSyncedAnimationStyle(true, spinDurationMs);
|
const syncedStyle = useSyncedAnimationStyle(spinning, spinDurationMs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Loader2
|
<Loader2
|
||||||
{...props}
|
{...props}
|
||||||
className={cn('animate-spin', className)}
|
className={cn(spinning && 'animate-spin', className)}
|
||||||
style={{ ...syncedStyle, ...style }}
|
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();
|
initSentryRenderer();
|
||||||
|
|
||||||
let root: ReactDOM.Root | null = null;
|
let root: ReactDOM.Root | null = null;
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,78 @@
|
||||||
import * as SentryElectron from '@sentry/electron/renderer';
|
import * as SentryElectron from '@sentry/electron/renderer';
|
||||||
import { browserTracingIntegration as reactBrowserTracing, init as reactInit } from '@sentry/react';
|
import { browserTracingIntegration as reactBrowserTracing, init as reactInit } from '@sentry/react';
|
||||||
import {
|
import {
|
||||||
|
filterSafeSentryIntegrations,
|
||||||
isValidDsn,
|
isValidDsn,
|
||||||
|
redactSentryEvent,
|
||||||
SENTRY_ENVIRONMENT,
|
SENTRY_ENVIRONMENT,
|
||||||
SENTRY_RELEASE,
|
SENTRY_RELEASE,
|
||||||
TRACES_SAMPLE_RATE,
|
TRACES_SAMPLE_RATE,
|
||||||
} from '@shared/utils/sentryConfig';
|
} from '@shared/utils/sentryConfig';
|
||||||
|
|
||||||
|
import type { ElectronAPI } from '@shared/types/api';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Telemetry gate (mirrors src/main/sentry.ts pattern)
|
// Telemetry gate (mirrors src/main/sentry.ts pattern)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Defaults to `true` so early renderer crashes are captured.
|
// Start closed until persisted config is loaded through the store.
|
||||||
// Synced to user's telemetryEnabled preference via syncRendererTelemetry().
|
let telemetryAllowed = false;
|
||||||
let telemetryAllowed = true;
|
|
||||||
let initialized = 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
|
* 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 {
|
export function syncRendererTelemetry(enabled: boolean): void {
|
||||||
telemetryAllowed = enabled;
|
telemetryAllowed = enabled;
|
||||||
if (!enabled && initialized && typeof SentryElectron.setUser === 'function') {
|
if (!enabled) {
|
||||||
SentryElectron.setUser(null);
|
telemetryIdentitySyncToken++;
|
||||||
|
clearRendererSentryUser();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initSentryRenderer();
|
||||||
|
void syncRendererTelemetryIdentity();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initSentryRenderer(): void {
|
export function initSentryRenderer(): void {
|
||||||
if (initialized) return;
|
if (initialized || !telemetryAllowed) return;
|
||||||
|
|
||||||
const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined;
|
const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined;
|
||||||
if (!isValidDsn(dsn)) return;
|
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
|
// 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
|
// 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) {
|
if (getElectronApi()) {
|
||||||
// Electron renderer — uses IPC transport to main process.
|
// Electron renderer - uses IPC transport to main process.
|
||||||
// browserTracingIntegration from @sentry/electron/renderer to avoid
|
// browserTracingIntegration from @sentry/electron/renderer to avoid
|
||||||
// @sentry/core version mismatch with @sentry/react.
|
// @sentry/core version mismatch with @sentry/react.
|
||||||
SentryElectron.init({
|
SentryElectron.init({
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
beforeSend,
|
beforeSend,
|
||||||
beforeSendTransaction,
|
beforeSendTransaction,
|
||||||
integrations: [SentryElectron.browserTracingIntegration()],
|
integrations: (integrations) => [
|
||||||
|
...filterSafeSentryIntegrations(integrations),
|
||||||
|
SentryElectron.browserTracingIntegration(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Standalone browser mode — direct HTTP transport
|
// Standalone browser mode - direct HTTP transport
|
||||||
reactInit({
|
reactInit({
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
beforeSend,
|
beforeSend,
|
||||||
beforeSendTransaction,
|
beforeSendTransaction,
|
||||||
integrations: [reactBrowserTracing()],
|
integrations: (integrations) => [
|
||||||
|
...filterSafeSentryIntegrations(integrations),
|
||||||
|
reactBrowserTracing(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
void syncRendererTelemetryIdentity();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the renderer SDK was successfully initialised. */
|
/** Whether the renderer SDK was successfully initialised. */
|
||||||
|
|
@ -88,11 +159,11 @@ export function isSentryRendererActive(): boolean {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Record a navigation breadcrumb (tab switches). */
|
/** 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;
|
if (!initialized) return;
|
||||||
SentryElectron.addBreadcrumb({
|
SentryElectron.addBreadcrumb({
|
||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
message: `Tab: ${from} → ${to}`,
|
message: 'tab-change',
|
||||||
level: 'info',
|
level: 'info',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -101,17 +172,18 @@ export function addNavigationBreadcrumb(from: string, to: string): void {
|
||||||
export function addRendererBreadcrumb(
|
export function addRendererBreadcrumb(
|
||||||
category: string,
|
category: string,
|
||||||
message: string,
|
message: string,
|
||||||
data?: Record<string, unknown>
|
_data?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
SentryElectron.addBreadcrumb({ category, message, data, level: 'info' });
|
SentryElectron.addBreadcrumb({ category, message, level: 'info' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capture an exception with optional extra context. */
|
/** Capture an exception with optional extra context. */
|
||||||
export function captureRendererException(error: Error, context?: Record<string, unknown>): void {
|
export function captureRendererException(error: Error, context?: Record<string, unknown>): void {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
SentryElectron.withScope((scope) => {
|
SentryElectron.withScope((scope) => {
|
||||||
if (context) scope.setContext('react', context);
|
const safeContext = getSafeRendererErrorContext(context);
|
||||||
|
if (safeContext) scope.setContext('react', safeContext);
|
||||||
SentryElectron.captureException(error);
|
SentryElectron.captureException(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
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_ATTEMPTS = 3;
|
||||||
const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
|
const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
|
||||||
|
|
||||||
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = [
|
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = isGeminiUiFrozen()
|
||||||
'anthropic',
|
? ['anthropic', 'codex', 'opencode']
|
||||||
'codex',
|
: ['anthropic', 'codex', 'gemini', 'opencode'];
|
||||||
'gemini',
|
const MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(MULTIMODEL_PROVIDER_IDS);
|
||||||
'opencode',
|
|
||||||
];
|
function isActiveMultimodelProviderId(providerId: CliProviderId): boolean {
|
||||||
|
return MULTIMODEL_PROVIDER_ID_SET.has(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
||||||
const providers: CliProviderStatus[] = (
|
const providers: CliProviderStatus[] = MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
|
||||||
[
|
providerId,
|
||||||
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
displayName: getProviderDisplayName(providerId),
|
||||||
{ providerId: 'codex', displayName: 'Codex' },
|
|
||||||
{ providerId: 'gemini', displayName: 'Gemini' },
|
|
||||||
{ providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
|
|
||||||
] as const
|
|
||||||
).map((provider) => ({
|
|
||||||
...provider,
|
|
||||||
supported: false,
|
supported: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'unknown' as const,
|
verificationState: 'unknown' as const,
|
||||||
modelVerificationState: 'idle' as const,
|
modelVerificationState: 'idle' as const,
|
||||||
|
modelCatalogRefreshState: 'idle' as const,
|
||||||
statusMessage: 'Checking...',
|
statusMessage: 'Checking...',
|
||||||
models: [],
|
models: [],
|
||||||
modelAvailability: [],
|
modelAvailability: [],
|
||||||
canLoginFromUi: provider.providerId !== 'opencode',
|
canLoginFromUi: providerId !== 'opencode',
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: 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 (
|
return (
|
||||||
provider.supported === false &&
|
provider.supported === false &&
|
||||||
provider.authenticated === 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(
|
export function getIncompleteMultimodelProviderIds(
|
||||||
status: CliInstallationStatus | null
|
status: CliInstallationStatus | null
|
||||||
): CliProviderId[] {
|
): CliProviderId[] {
|
||||||
|
|
@ -180,7 +201,11 @@ export function getIncompleteMultimodelProviderIds(
|
||||||
}
|
}
|
||||||
|
|
||||||
return status.providers
|
return status.providers
|
||||||
.filter((provider) => !isHydratedMultimodelProviderStatus(provider))
|
.filter(
|
||||||
|
(provider) =>
|
||||||
|
isActiveMultimodelProviderId(provider.providerId) &&
|
||||||
|
!isHydratedMultimodelProviderStatus(provider)
|
||||||
|
)
|
||||||
.map((provider) => provider.providerId);
|
.map((provider) => provider.providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +217,11 @@ export function getModelOnlyFallbackProviderIds(
|
||||||
}
|
}
|
||||||
|
|
||||||
return status.providers
|
return status.providers
|
||||||
.filter((provider) => isModelOnlyFallbackProviderStatus(provider))
|
.filter(
|
||||||
|
(provider) =>
|
||||||
|
isActiveMultimodelProviderId(provider.providerId) &&
|
||||||
|
isModelOnlyFallbackProviderStatus(provider)
|
||||||
|
)
|
||||||
.map((provider) => provider.providerId);
|
.map((provider) => provider.providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,12 +234,20 @@ export function reconcileMultimodelProviderLoading(
|
||||||
}
|
}
|
||||||
|
|
||||||
const incompleteProviderIds = new Set(getIncompleteMultimodelProviderIds(status));
|
const incompleteProviderIds = new Set(getIncompleteMultimodelProviderIds(status));
|
||||||
return status.providers.reduce<Partial<Record<CliProviderId, boolean>>>(
|
const providersById = new Map(
|
||||||
(nextLoading, provider) => ({
|
status.providers.map((provider) => [provider.providerId, provider])
|
||||||
...nextLoading,
|
);
|
||||||
[provider.providerId]: incompleteProviderIds.has(provider.providerId),
|
return MULTIMODEL_PROVIDER_IDS.reduce<Partial<Record<CliProviderId, boolean>>>(
|
||||||
}),
|
(nextLoading, providerId) => {
|
||||||
{ ...currentLoading }
|
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.authMethod === b.authMethod &&
|
||||||
a.verificationState === b.verificationState &&
|
a.verificationState === b.verificationState &&
|
||||||
(a.modelVerificationState ?? null) === (b.modelVerificationState ?? null) &&
|
(a.modelVerificationState ?? null) === (b.modelVerificationState ?? null) &&
|
||||||
|
(a.modelCatalogRefreshState ?? null) === (b.modelCatalogRefreshState ?? null) &&
|
||||||
(a.statusMessage ?? null) === (b.statusMessage ?? null) &&
|
(a.statusMessage ?? null) === (b.statusMessage ?? null) &&
|
||||||
(a.detailMessage ?? null) === (b.detailMessage ?? null) &&
|
(a.detailMessage ?? null) === (b.detailMessage ?? null) &&
|
||||||
a.canLoginFromUi === b.canLoginFromUi &&
|
a.canLoginFromUi === b.canLoginFromUi &&
|
||||||
|
|
@ -374,7 +412,7 @@ export function mergeCliStatusPreservingHydratedProviders(
|
||||||
return incomingProvider;
|
return incomingProvider;
|
||||||
}
|
}
|
||||||
if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
|
if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
|
||||||
return currentProvider;
|
return mergeProviderCatalogCache(incomingProvider, currentProvider);
|
||||||
}
|
}
|
||||||
// Preserve the current reference when content is identical so the
|
// Preserve the current reference when content is identical so the
|
||||||
// providers array stays reference-stable across steady-state IPC polls.
|
// providers array stays reference-stable across steady-state IPC polls.
|
||||||
|
|
@ -387,13 +425,14 @@ export function mergeCliStatusPreservingHydratedProviders(
|
||||||
for (const currentProvider of current.providers) {
|
for (const currentProvider of current.providers) {
|
||||||
if (
|
if (
|
||||||
!incomingProviderIds.has(currentProvider.providerId) &&
|
!incomingProviderIds.has(currentProvider.providerId) &&
|
||||||
|
isActiveMultimodelProviderId(currentProvider.providerId) &&
|
||||||
isHydratedMultimodelProviderStatus(currentProvider)
|
isHydratedMultimodelProviderStatus(currentProvider)
|
||||||
) {
|
) {
|
||||||
providers.push(currentProvider);
|
providers.push(currentProvider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticatedProvider = providers.find((provider) => provider.authenticated) ?? null;
|
const authenticatedProvider = getAuthenticatedProvider(providers);
|
||||||
|
|
||||||
const mergedProviders = areArraysEqual(providers, current.providers, Object.is)
|
const mergedProviders = areArraysEqual(providers, current.providers, Object.is)
|
||||||
? current.providers
|
? current.providers
|
||||||
|
|
@ -402,7 +441,9 @@ export function mergeCliStatusPreservingHydratedProviders(
|
||||||
const merged: CliInstallationStatus = {
|
const merged: CliInstallationStatus = {
|
||||||
...incoming,
|
...incoming,
|
||||||
providers: mergedProviders,
|
providers: mergedProviders,
|
||||||
authLoggedIn: mergedProviders.some((provider) => provider.authenticated),
|
authLoggedIn: mergedProviders.some(
|
||||||
|
(provider) => isActiveMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||||
|
),
|
||||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -468,11 +509,15 @@ function isMultimodelCliStatus(
|
||||||
function hasActiveProviderStatusLoading(
|
function hasActiveProviderStatusLoading(
|
||||||
providerLoading: Partial<Record<CliProviderId, boolean>>
|
providerLoading: Partial<Record<CliProviderId, boolean>>
|
||||||
): 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 {
|
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: {
|
function buildMultimodelCliAuthState(params: {
|
||||||
|
|
@ -485,7 +530,9 @@ function buildMultimodelCliAuthState(params: {
|
||||||
const authenticatedProvider = getAuthenticatedProvider(providers);
|
const authenticatedProvider = getAuthenticatedProvider(providers);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authLoggedIn: providers.some((provider) => provider.authenticated),
|
authLoggedIn: providers.some(
|
||||||
|
(provider) => isActiveMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||||
|
),
|
||||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||||
authStatusChecking: params.status.installed && hasActiveProviderStatusLoading(providerLoading),
|
authStatusChecking: params.status.installed && hasActiveProviderStatusLoading(providerLoading),
|
||||||
};
|
};
|
||||||
|
|
@ -513,7 +560,27 @@ function createProviderStatusErrorSnapshot(params: {
|
||||||
params.currentProvider ??
|
params.currentProvider ??
|
||||||
createLoadingMultimodelCliStatus().providers.find(
|
createLoadingMultimodelCliStatus().providers.find(
|
||||||
(provider) => provider.providerId === params.providerId
|
(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 {
|
return {
|
||||||
...currentProvider,
|
...currentProvider,
|
||||||
|
|
@ -522,6 +589,7 @@ function createProviderStatusErrorSnapshot(params: {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'error',
|
verificationState: 'error',
|
||||||
|
modelCatalogRefreshState: 'error',
|
||||||
statusMessage: params.message,
|
statusMessage: params.message,
|
||||||
detailMessage: null,
|
detailMessage: null,
|
||||||
};
|
};
|
||||||
|
|
@ -764,6 +832,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
});
|
});
|
||||||
if (status.installed) {
|
if (status.installed) {
|
||||||
for (const provider of status.providers) {
|
for (const provider of status.providers) {
|
||||||
|
if (!isActiveMultimodelProviderId(provider.providerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
void get().fetchCliProviderStatus(provider.providerId, {
|
void get().fetchCliProviderStatus(provider.providerId, {
|
||||||
silent: true,
|
silent: true,
|
||||||
epoch,
|
epoch,
|
||||||
|
|
@ -848,12 +919,30 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
}
|
}
|
||||||
|
|
||||||
const settledCliStatus: CliInstallationStatus = currentCliStatus;
|
const settledCliStatus: CliInstallationStatus = currentCliStatus;
|
||||||
|
if (
|
||||||
|
isMultimodelCliStatus(settledCliStatus) &&
|
||||||
|
!isActiveMultimodelProviderId(providerId)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
cliProviderStatusLoading: nextLoading,
|
||||||
|
cliStatus: {
|
||||||
|
...settledCliStatus,
|
||||||
|
...buildMultimodelCliAuthState({
|
||||||
|
status: settledCliStatus,
|
||||||
|
providerLoading: nextLoading,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const hasProvider = settledCliStatus.providers.some(
|
const hasProvider = settledCliStatus.providers.some(
|
||||||
(provider) => provider.providerId === providerId
|
(provider) => provider.providerId === providerId
|
||||||
);
|
);
|
||||||
const nextProviders = hasProvider
|
const nextProviders = hasProvider
|
||||||
? settledCliStatus.providers.map((provider) =>
|
? settledCliStatus.providers.map((provider) =>
|
||||||
provider.providerId === providerId ? providerStatus : provider
|
provider.providerId === providerId
|
||||||
|
? mergeProviderCatalogCache(providerStatus, provider)
|
||||||
|
: provider
|
||||||
)
|
)
|
||||||
: [...settledCliStatus.providers, providerStatus];
|
: [...settledCliStatus.providers, providerStatus];
|
||||||
const nextCliStatus = isMultimodelCliStatus(settledCliStatus)
|
const nextCliStatus = isMultimodelCliStatus(settledCliStatus)
|
||||||
|
|
@ -906,6 +995,22 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
}
|
}
|
||||||
|
|
||||||
const settledCliStatus: CliInstallationStatus = currentCliStatus;
|
const settledCliStatus: CliInstallationStatus = currentCliStatus;
|
||||||
|
if (
|
||||||
|
isMultimodelCliStatus(settledCliStatus) &&
|
||||||
|
!isActiveMultimodelProviderId(providerId)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
cliProviderStatusLoading: nextLoading,
|
||||||
|
cliStatus: {
|
||||||
|
...settledCliStatus,
|
||||||
|
...buildMultimodelCliAuthState({
|
||||||
|
status: settledCliStatus,
|
||||||
|
providerLoading: nextLoading,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const currentProvider =
|
const currentProvider =
|
||||||
settledCliStatus.providers.find((provider) => provider.providerId === providerId) ??
|
settledCliStatus.providers.find((provider) => provider.providerId === providerId) ??
|
||||||
undefined;
|
undefined;
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import type {
|
||||||
TaskChangePresenceState,
|
TaskChangePresenceState,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
|
TeamAgentRuntimeResourceSample,
|
||||||
TeamAgentRuntimeSnapshot,
|
TeamAgentRuntimeSnapshot,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamGetDataOptions,
|
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(
|
function areTeamAgentRuntimeEntriesEqual(
|
||||||
left: TeamAgentRuntimeEntry | undefined,
|
left: TeamAgentRuntimeEntry | undefined,
|
||||||
right: TeamAgentRuntimeEntry | undefined
|
right: TeamAgentRuntimeEntry | undefined
|
||||||
): boolean {
|
): boolean {
|
||||||
if (left === right) return true;
|
if (left === right) return true;
|
||||||
if (!left || !right) return left === right;
|
if (!left || !right) return left === right;
|
||||||
const leftDiagnostics = left.diagnostics ?? [];
|
const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : [];
|
||||||
const rightDiagnostics = right.diagnostics ?? [];
|
const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : [];
|
||||||
const leftResourceHistory = left.resourceHistory ?? [];
|
const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : [];
|
||||||
const rightResourceHistory = right.resourceHistory ?? [];
|
const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : [];
|
||||||
return (
|
return (
|
||||||
left.memberName === right.memberName &&
|
left.memberName === right.memberName &&
|
||||||
left.alive === right.alive &&
|
left.alive === right.alive &&
|
||||||
|
|
@ -1001,6 +1030,13 @@ function areTeamAgentRuntimeEntriesEqual(
|
||||||
left.runtimeModel === right.runtimeModel &&
|
left.runtimeModel === right.runtimeModel &&
|
||||||
left.rssBytes === right.rssBytes &&
|
left.rssBytes === right.rssBytes &&
|
||||||
left.cpuPercent === right.cpuPercent &&
|
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.livenessKind === right.livenessKind &&
|
||||||
left.pidSource === right.pidSource &&
|
left.pidSource === right.pidSource &&
|
||||||
left.processCommand === right.processCommand &&
|
left.processCommand === right.processCommand &&
|
||||||
|
|
@ -1016,17 +1052,9 @@ function areTeamAgentRuntimeEntriesEqual(
|
||||||
leftDiagnostics.length === rightDiagnostics.length &&
|
leftDiagnostics.length === rightDiagnostics.length &&
|
||||||
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
|
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
|
||||||
leftResourceHistory.length === rightResourceHistory.length &&
|
leftResourceHistory.length === rightResourceHistory.length &&
|
||||||
leftResourceHistory.every((value, index) => {
|
leftResourceHistory.every((value, index) =>
|
||||||
const other = rightResourceHistory[index];
|
areTeamAgentRuntimeResourceSamplesEqual(value, 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
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -304,10 +304,64 @@ function formatRetryCountdown(ms: number): string {
|
||||||
return `${totalSeconds}s`;
|
return `${totalSeconds}s`;
|
||||||
}
|
}
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
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;
|
const seconds = totalSeconds % 60;
|
||||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
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 {
|
function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined): string | null {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
|
|
@ -461,12 +515,20 @@ function formatRuntimeAdvisoryTitle(
|
||||||
switch (advisory.reasonCode) {
|
switch (advisory.reasonCode) {
|
||||||
case 'quota_exhausted':
|
case 'quota_exhausted':
|
||||||
return appendRuntimeAdvisoryRawMessage(
|
return appendRuntimeAdvisoryRawMessage(
|
||||||
`${providerLabel ?? 'Provider'} quota exhausted.`,
|
appendRuntimeAdvisoryRetryHint(
|
||||||
|
`${providerLabel ?? 'Provider'} quota exhausted.`,
|
||||||
|
advisory,
|
||||||
|
providerId
|
||||||
|
),
|
||||||
advisory.message
|
advisory.message
|
||||||
);
|
);
|
||||||
case 'rate_limited':
|
case 'rate_limited':
|
||||||
return appendRuntimeAdvisoryRawMessage(
|
return appendRuntimeAdvisoryRawMessage(
|
||||||
`${providerLabel ?? 'Provider'} rate limited the request.`,
|
appendRuntimeAdvisoryRetryHint(
|
||||||
|
`${providerLabel ?? 'Provider'} rate limited the request.`,
|
||||||
|
advisory,
|
||||||
|
providerId
|
||||||
|
),
|
||||||
advisory.message
|
advisory.message
|
||||||
);
|
);
|
||||||
case 'auth_error':
|
case 'auth_error':
|
||||||
|
|
@ -584,18 +646,17 @@ export function getMemberRuntimeAdvisoryLabel(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
|
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
|
||||||
|
const remainingMs = getRuntimeAdvisoryRetryRemainingMs(advisory, nowMs);
|
||||||
if (advisory.kind === 'api_error') {
|
if (advisory.kind === 'api_error') {
|
||||||
|
if (remainingMs && isRetryTimedApiAdvisory(advisory, providerId)) {
|
||||||
|
return `${baseLabel} · retry ${formatRetryCountdown(remainingMs)}`;
|
||||||
|
}
|
||||||
return baseLabel;
|
return baseLabel;
|
||||||
}
|
}
|
||||||
if (advisory.kind !== 'sdk_retrying') {
|
if (advisory.kind !== 'sdk_retrying') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const retryUntilMs = advisory.retryUntil ? Date.parse(advisory.retryUntil) : Number.NaN;
|
if (!remainingMs) {
|
||||||
if (!Number.isFinite(retryUntilMs)) {
|
|
||||||
return baseLabel;
|
|
||||||
}
|
|
||||||
const remainingMs = retryUntilMs - nowMs;
|
|
||||||
if (remainingMs <= 0) {
|
|
||||||
return baseLabel;
|
return baseLabel;
|
||||||
}
|
}
|
||||||
return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`;
|
return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
MemberLaunchState,
|
MemberLaunchState,
|
||||||
|
MemberRuntimeAdvisory,
|
||||||
MemberSpawnLivenessSource,
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
|
|
@ -49,6 +50,11 @@ export interface MemberLaunchDiagnosticsPayload {
|
||||||
rssBytes?: number;
|
rssBytes?: number;
|
||||||
runtimeDiagnostic?: string;
|
runtimeDiagnostic?: string;
|
||||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||||
|
runtimeAdvisoryKind?: MemberRuntimeAdvisory['kind'];
|
||||||
|
runtimeAdvisoryReasonCode?: MemberRuntimeAdvisory['reasonCode'];
|
||||||
|
runtimeAdvisoryObservedAt?: string;
|
||||||
|
runtimeAdvisoryRetryUntil?: string;
|
||||||
|
runtimeAdvisoryRetryDelayMs?: number;
|
||||||
bootstrapStalled?: boolean;
|
bootstrapStalled?: boolean;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
firstSpawnAcceptedAt?: string;
|
firstSpawnAcceptedAt?: string;
|
||||||
|
|
@ -240,25 +246,39 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
||||||
livenessSource?: MemberSpawnLivenessSource;
|
livenessSource?: MemberSpawnLivenessSource;
|
||||||
spawnEntry?: MemberSpawnStatusEntry;
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
runtimeAdvisory?: MemberRuntimeAdvisory;
|
||||||
|
runtimeAdvisoryLabel?: string | null;
|
||||||
|
runtimeAdvisoryTitle?: string;
|
||||||
}): MemberLaunchDiagnosticsPayload {
|
}): MemberLaunchDiagnosticsPayload {
|
||||||
const spawnEntry = params.spawnEntry;
|
const spawnEntry = params.spawnEntry;
|
||||||
const runtimeEntry = params.runtimeEntry;
|
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 =
|
const runtimeDiagnostic =
|
||||||
boundedString(spawnEntry?.runtimeDiagnostic) ??
|
boundedString(spawnEntry?.runtimeDiagnostic) ??
|
||||||
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
||||||
boundedString(spawnEntry?.hardFailureReason) ??
|
boundedString(spawnEntry?.hardFailureReason) ??
|
||||||
boundedString(spawnEntry?.error);
|
boundedString(spawnEntry?.error) ??
|
||||||
const memberCardError = boundedString(
|
runtimeAdvisoryMessage;
|
||||||
normalizeMemberLaunchFailureReason(
|
const memberCardError =
|
||||||
spawnEntry?.error ??
|
boundedString(
|
||||||
spawnEntry?.hardFailureReason ??
|
normalizeMemberLaunchFailureReason(
|
||||||
spawnEntry?.runtimeDiagnostic ??
|
spawnEntry?.error ??
|
||||||
runtimeEntry?.runtimeDiagnostic
|
spawnEntry?.hardFailureReason ??
|
||||||
) ?? undefined
|
spawnEntry?.runtimeDiagnostic ??
|
||||||
);
|
runtimeEntry?.runtimeDiagnostic
|
||||||
|
) ?? undefined
|
||||||
|
) ?? runtimeAdvisoryCardError;
|
||||||
const diagnostics = uniqueDiagnostics(
|
const diagnostics = uniqueDiagnostics(
|
||||||
memberCardError ? [memberCardError] : undefined,
|
memberCardError ? [memberCardError] : undefined,
|
||||||
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
||||||
|
runtimeAdvisoryTitle ? [runtimeAdvisoryTitle] : undefined,
|
||||||
|
runtimeAdvisoryLabel ? [runtimeAdvisoryLabel] : undefined,
|
||||||
|
runtimeAdvisoryMessage ? [runtimeAdvisoryMessage] : undefined,
|
||||||
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
||||||
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
||||||
runtimeEntry?.diagnostics
|
runtimeEntry?.diagnostics
|
||||||
|
|
@ -370,6 +390,19 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
||||||
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
|
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 } : {}),
|
...(spawnEntry?.bootstrapStalled === true ? { bootstrapStalled: true } : {}),
|
||||||
...(boundedStringArray(spawnEntry?.pendingPermissionRequestIds)
|
...(boundedStringArray(spawnEntry?.pendingPermissionRequestIds)
|
||||||
? { pendingPermissionRequestIds: boundedStringArray(spawnEntry?.pendingPermissionRequestIds) }
|
? { pendingPermissionRequestIds: boundedStringArray(spawnEntry?.pendingPermissionRequestIds) }
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ function appendRuntimeSummarySuffixes(
|
||||||
export function getRuntimeMemorySourceLabel(
|
export function getRuntimeMemorySourceLabel(
|
||||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
if (runtimeEntry?.runtimeLoadScope === 'shared-host') {
|
||||||
|
return 'RSS source: shared OpenCode host';
|
||||||
|
}
|
||||||
if (!runtimeEntry?.pidSource) {
|
if (!runtimeEntry?.pidSource) {
|
||||||
return undefined;
|
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 }[]>;
|
) => 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
|
// Main Electron API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -799,6 +812,7 @@ export interface ReviewAPI {
|
||||||
*/
|
*/
|
||||||
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
|
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
|
||||||
startup?: AppStartupAPI;
|
startup?: AppStartupAPI;
|
||||||
|
telemetry: TelemetryAPI;
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
getProjects: () => Promise<Project[]>;
|
getProjects: () => Promise<Project[]>;
|
||||||
getSessions: (projectId: string) => Promise<Session[]>;
|
getSessions: (projectId: string) => Promise<Session[]>;
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@ export interface CliProviderStatus {
|
||||||
detailMessage?: string | null;
|
detailMessage?: string | null;
|
||||||
models: string[];
|
models: string[];
|
||||||
modelCatalog?: CliProviderModelCatalog | null;
|
modelCatalog?: CliProviderModelCatalog | null;
|
||||||
|
modelCatalogRefreshState?: 'idle' | 'loading' | 'ready' | 'error';
|
||||||
modelAvailability?: CliProviderModelAvailability[];
|
modelAvailability?: CliProviderModelAvailability[];
|
||||||
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
|
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
|
||||||
subscriptionRateLimits?: CliProviderSubscriptionRateLimitSnapshot | null;
|
subscriptionRateLimits?: CliProviderSubscriptionRateLimitSnapshot | null;
|
||||||
|
|
|
||||||
|
|
@ -1203,11 +1203,20 @@ export interface TeamAgentRuntimeResourceSample {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
cpuPercent?: number;
|
cpuPercent?: number;
|
||||||
rssBytes?: number;
|
rssBytes?: number;
|
||||||
|
primaryCpuPercent?: number;
|
||||||
|
primaryRssBytes?: number;
|
||||||
|
childCpuPercent?: number;
|
||||||
|
childRssBytes?: number;
|
||||||
|
processCount?: number;
|
||||||
|
runtimeLoadScope?: TeamAgentRuntimeLoadScope;
|
||||||
|
runtimeLoadTruncated?: boolean;
|
||||||
pidSource?: TeamAgentRuntimePidSource;
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TeamAgentRuntimeLoadScope = 'single-process' | 'process-tree' | 'shared-host';
|
||||||
|
|
||||||
export interface TeamAgentRuntimeEntry {
|
export interface TeamAgentRuntimeEntry {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
alive: boolean;
|
alive: boolean;
|
||||||
|
|
@ -1223,6 +1232,13 @@ export interface TeamAgentRuntimeEntry {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
rssBytes?: number;
|
rssBytes?: number;
|
||||||
cpuPercent?: number;
|
cpuPercent?: number;
|
||||||
|
primaryCpuPercent?: number;
|
||||||
|
primaryRssBytes?: number;
|
||||||
|
childCpuPercent?: number;
|
||||||
|
childRssBytes?: number;
|
||||||
|
processCount?: number;
|
||||||
|
runtimeLoadScope?: TeamAgentRuntimeLoadScope;
|
||||||
|
runtimeLoadTruncated?: boolean;
|
||||||
resourceHistory?: TeamAgentRuntimeResourceSample[];
|
resourceHistory?: TeamAgentRuntimeResourceSample[];
|
||||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
pidSource?: TeamAgentRuntimePidSource;
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
|
|
@ -1307,6 +1323,12 @@ export interface MemberSpawnStatusEntry {
|
||||||
hardFailure?: boolean;
|
hardFailure?: boolean;
|
||||||
/** Pending runtime permission request ids currently blocking bootstrap. */
|
/** Pending runtime permission request ids currently blocking bootstrap. */
|
||||||
pendingPermissionRequestIds?: string[];
|
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. */
|
/** ISO timestamp of the first accepted teammate spawn for this member. */
|
||||||
firstSpawnAcceptedAt?: string;
|
firstSpawnAcceptedAt?: string;
|
||||||
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */
|
/** 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.
|
* Shared Sentry configuration constants.
|
||||||
*
|
*
|
||||||
* Used by both main and renderer process init modules.
|
* 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).
|
* (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 {
|
export function isValidDsn(dsn: string | undefined): dsn is string {
|
||||||
return typeof dsn === 'string' && dsn.length > 0 && dsn.startsWith('https://');
|
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';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
describe('main Sentry telemetry gate', () => {
|
describe('main Sentry telemetry gate', () => {
|
||||||
|
|
@ -18,20 +22,99 @@ describe('main Sentry telemetry gate', () => {
|
||||||
vi.resetModules();
|
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 () => {
|
it('clears user scope and drops events when telemetry is disabled', async () => {
|
||||||
const sentry = await import('@main/sentry');
|
const sentry = await import('@main/sentry');
|
||||||
const sentryApi = {
|
const sentryApi = {
|
||||||
setUser: vi.fn(),
|
setUser: vi.fn(),
|
||||||
setTags: vi.fn(),
|
setTags: vi.fn(),
|
||||||
|
close: vi.fn(() => Promise.resolve(true)),
|
||||||
};
|
};
|
||||||
sentry.setMainSentryApiForTesting(sentryApi);
|
sentry.setMainSentryApiForTesting(sentryApi);
|
||||||
|
|
||||||
sentry.syncTelemetryFlag(false);
|
sentry.syncTelemetryFlag(false);
|
||||||
|
|
||||||
expect(sentryApi.setUser).toHaveBeenCalledWith(null);
|
expect(sentryApi.setUser).toHaveBeenCalledWith(null);
|
||||||
|
expect(sentryApi.close).toHaveBeenCalled();
|
||||||
expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull();
|
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 () => {
|
it('only exposes safe low-cardinality telemetry tags', async () => {
|
||||||
const { getSafeSentryTelemetryTags } = await import('@main/sentry');
|
const { getSafeSentryTelemetryTags } = await import('@main/sentry');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ describe('CliInstallerService', () => {
|
||||||
expect(status.updateAvailable).toBe(false);
|
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();
|
allowConsoleLogs();
|
||||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||||
|
|
@ -147,7 +147,6 @@ describe('CliInstallerService', () => {
|
||||||
expect(status.providers.map((provider) => provider.providerId)).toEqual([
|
expect(status.providers.map((provider) => provider.providerId)).toEqual([
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'codex',
|
'codex',
|
||||||
'gemini',
|
|
||||||
'opencode',
|
'opencode',
|
||||||
]);
|
]);
|
||||||
expect(openCodeStatus).toMatchObject({
|
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 () => {
|
it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ const execCliMock = vi.fn();
|
||||||
const buildProviderAwareCliEnvMock = vi.fn();
|
const buildProviderAwareCliEnvMock = vi.fn();
|
||||||
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
|
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
|
||||||
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>();
|
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));
|
const enrichProviderStatusesMock = vi.fn((providers) => Promise.resolve(providers));
|
||||||
|
|
||||||
vi.mock('@main/utils/childProcess', () => ({
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
|
|
@ -22,6 +24,7 @@ vi.mock('@main/utils/childProcess', () => ({
|
||||||
|
|
||||||
vi.mock('@main/utils/shellEnv', () => ({
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
|
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
|
||||||
|
resolveInteractiveShellEnvBestEffort: () => resolveInteractiveShellEnvMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('fs', () => ({
|
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) => {
|
execCliMock.mockImplementation((_binaryPath, args, options) => {
|
||||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
const env = options?.env ?? {};
|
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') {
|
if (normalizedArgs === 'auth status --json --provider all') {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
|
|
@ -183,7 +193,12 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
|
|
||||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
|
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({
|
expect(providers[0]).toMatchObject({
|
||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
|
@ -205,6 +220,20 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(providers[2]).toMatchObject({
|
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',
|
providerId: 'gemini',
|
||||||
displayName: 'Gemini',
|
displayName: 'Gemini',
|
||||||
supported: true,
|
supported: true,
|
||||||
|
|
@ -219,21 +248,102 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
projectId: 'demo-project',
|
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 = {
|
const providerPayloads = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
supported: true,
|
supported: true,
|
||||||
|
|
@ -311,24 +421,31 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
|
|
||||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate);
|
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(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'runtime status --json --provider anthropic',
|
'runtime status --json --provider anthropic --summary',
|
||||||
'runtime status --json --provider codex',
|
'runtime status --json --provider codex --summary',
|
||||||
'runtime status --json --provider gemini',
|
'runtime status --json --provider opencode --summary',
|
||||||
'runtime status --json --provider opencode',
|
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).not.toContain(
|
||||||
|
'runtime status --json --provider gemini --summary'
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
execCliMock.mock.calls
|
execCliMock.mock.calls
|
||||||
.filter((call) => call[1].join(' ').startsWith('runtime status --json --provider '))
|
.filter((call) => call[1].join(' ').startsWith('runtime status --json --provider '))
|
||||||
.map((call) => call[2]?.maxBuffer)
|
.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([
|
expect(providers.map((provider) => provider.providerId)).toEqual([
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'codex',
|
'codex',
|
||||||
'gemini',
|
|
||||||
'opencode',
|
'opencode',
|
||||||
]);
|
]);
|
||||||
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
|
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
|
||||||
|
|
@ -340,6 +457,717 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual(providers);
|
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 () => {
|
it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
|
||||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||||
env: { HOME: '/Users/tester' },
|
env: { HOME: '/Users/tester' },
|
||||||
|
|
@ -836,7 +1664,10 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
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({
|
return Promise.resolve({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
providers: {
|
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 () => {
|
it('returns the stored Anthropic API key for team helper mode only in api_key auth mode', async () => {
|
||||||
const lookupPreferred = vi.fn().mockResolvedValue({
|
const lookupPreferred = vi.fn().mockResolvedValue({
|
||||||
envVarName: 'ANTHROPIC_API_KEY',
|
envVarName: 'ANTHROPIC_API_KEY',
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,40 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
|
||||||
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded');
|
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', () => {
|
it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => {
|
||||||
const record = {
|
const record = {
|
||||||
diagnostics: [
|
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', () => {
|
it('selects auth errors over bridge timeouts', () => {
|
||||||
const selected = selectRuntimeDiagnosticClassification([
|
const selected = selectRuntimeDiagnosticClassification([
|
||||||
'OpenCode bridge command timed out',
|
'OpenCode bridge command timed out',
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,107 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
expect(advisory?.message).not.toContain('Latest assistant message');
|
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 () => {
|
it('classifies terminal OpenCode protocol proof failures as warnings, not provider errors', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
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[] = [];
|
const started: string[] = [];
|
||||||
let activeCount = 0;
|
let activeCount = 0;
|
||||||
let maxActiveCount = 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(() =>
|
await vi.waitFor(() =>
|
||||||
expect(started).toEqual(['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'])
|
expect(started).toEqual(['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'])
|
||||||
);
|
);
|
||||||
expect(maxActiveCount).toBe(2);
|
expect(maxActiveCount).toBe(1);
|
||||||
expect(releases.has('opencode/big-pickle')).toBe(false);
|
|
||||||
|
|
||||||
releases.get('opencode/nemotron-3-super-free')?.();
|
releases.get('opencode/nemotron-3-super-free')?.();
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
|
|
@ -1066,10 +1071,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
'opencode/big-pickle',
|
'opencode/big-pickle',
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
expect(maxActiveCount).toBe(2);
|
expect(maxActiveCount).toBe(1);
|
||||||
|
|
||||||
releases.get('opencode/big-pickle')?.();
|
releases.get('opencode/big-pickle')?.();
|
||||||
releases.get('opencode/minimax-m2.5-free')?.();
|
|
||||||
|
|
||||||
const result = await resultPromise;
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
|
@ -1079,7 +1083,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
'Selected model opencode/nemotron-3-super-free verified for launch.',
|
'Selected model opencode/nemotron-3-super-free verified for launch.',
|
||||||
]);
|
]);
|
||||||
expect(result.warnings).toEqual([
|
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 }[];
|
worktrees: { path: string }[];
|
||||||
}[];
|
}[];
|
||||||
teams: { teamName: string; displayName: 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;
|
const storeState = {} as StoreState;
|
||||||
|
|
@ -83,12 +86,21 @@ vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
|
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(
|
React.createElement(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
'data-testid': 'sidebar-task-item',
|
'data-testid': 'sidebar-task-item',
|
||||||
'data-hide-project-name': hideProjectName ? 'true' : 'false',
|
'data-hide-project-name': hideProjectName ? 'true' : 'false',
|
||||||
|
'data-team-offline': teamOffline ? 'true' : 'false',
|
||||||
},
|
},
|
||||||
task.subject
|
task.subject
|
||||||
),
|
),
|
||||||
|
|
@ -189,6 +201,9 @@ describe('GlobalTaskList project grouping', () => {
|
||||||
storeState.viewMode = 'flat';
|
storeState.viewMode = 'flat';
|
||||||
storeState.repositoryGroups = [];
|
storeState.repositoryGroups = [];
|
||||||
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
|
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
|
||||||
|
storeState.provisioningRuns = {};
|
||||||
|
storeState.currentProvisioningRunIdByTeam = {};
|
||||||
|
storeState.leadActivityByTeam = {};
|
||||||
toggleCollapsedGroup.mockReset();
|
toggleCollapsedGroup.mockReset();
|
||||||
taskLocalState.isPinned.mockClear();
|
taskLocalState.isPinned.mockClear();
|
||||||
taskLocalState.isArchived.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 () => {
|
it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));
|
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 () => {
|
it('can hide the project label when the parent already groups by project', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
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 () => {
|
it('virtualizes large OpenCode model lists instead of rendering every model tile', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const models = Array.from(
|
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 () => {
|
it('does not count generic one-shot diagnostic timeouts as model timeouts', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
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 () => {
|
it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {
|
||||||
const runtimeFailure =
|
const runtimeFailure =
|
||||||
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?';
|
'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();
|
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', () => ({
|
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
TooltipProvider: ({
|
||||||
React.createElement(React.Fragment, null, children),
|
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 }) =>
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||||
React.createElement(React.Fragment, null, children),
|
React.createElement(React.Fragment, null, children),
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
TooltipContent: ({
|
||||||
React.createElement('div', null, children),
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => React.createElement('div', { className, 'data-testid': 'tooltip-content' }, children),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/hooks/useTheme', () => ({
|
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 () => {
|
it('keeps runtime-pending launch status visible even when the teammate has an active task', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
@ -535,7 +724,7 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
restartable: false,
|
restartable: false,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
pid: 333,
|
pid: 333,
|
||||||
pidSource: 'opencode_bridge',
|
runtimeLoadScope: 'shared-host',
|
||||||
rssBytes: 183.9 * 1024 * 1024,
|
rssBytes: 183.9 * 1024 * 1024,
|
||||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
},
|
},
|
||||||
|
|
@ -575,11 +764,23 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
pidSource: 'tmux_child',
|
pidSource: 'tmux_child',
|
||||||
rssBytes: 238.3 * 1024 * 1024,
|
rssBytes: 238.3 * 1024 * 1024,
|
||||||
cpuPercent: 14,
|
cpuPercent: 14,
|
||||||
|
primaryCpuPercent: 4,
|
||||||
|
primaryRssBytes: 210 * 1024 * 1024,
|
||||||
|
childCpuPercent: 10,
|
||||||
|
childRssBytes: 28.3 * 1024 * 1024,
|
||||||
|
processCount: 3,
|
||||||
|
runtimeLoadScope: 'process-tree',
|
||||||
resourceHistory: [
|
resourceHistory: [
|
||||||
{
|
{
|
||||||
timestamp: '2026-04-24T12:00:00.000Z',
|
timestamp: '2026-04-24T12:00:00.000Z',
|
||||||
rssBytes: 220 * 1024 * 1024,
|
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',
|
pidSource: 'tmux_child',
|
||||||
pid: 222,
|
pid: 222,
|
||||||
},
|
},
|
||||||
|
|
@ -587,12 +788,23 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
timestamp: '2026-04-24T12:00:05.000Z',
|
timestamp: '2026-04-24T12:00:05.000Z',
|
||||||
rssBytes: 238.3 * 1024 * 1024,
|
rssBytes: 238.3 * 1024 * 1024,
|
||||||
cpuPercent: 14,
|
cpuPercent: 14,
|
||||||
|
primaryCpuPercent: 4,
|
||||||
|
primaryRssBytes: 210 * 1024 * 1024,
|
||||||
|
childCpuPercent: 10,
|
||||||
|
childRssBytes: 28.3 * 1024 * 1024,
|
||||||
|
processCount: 3,
|
||||||
|
runtimeLoadScope: 'process-tree',
|
||||||
pidSource: 'tmux_child',
|
pidSource: 'tmux_child',
|
||||||
pid: 222,
|
pid: 222,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
updatedAt: '2026-04-24T12:00:05.000Z',
|
updatedAt: '2026-04-24T12:00:05.000Z',
|
||||||
},
|
},
|
||||||
|
runtimeTelemetryVisible: true,
|
||||||
|
runtimeTelemetryScale: {
|
||||||
|
cpuCapPercent: 100,
|
||||||
|
memoryCapBytes: 512 * 1024 * 1024,
|
||||||
|
},
|
||||||
isTeamAlive: true,
|
isTeamAlive: true,
|
||||||
isTeamProvisioning: false,
|
isTeamProvisioning: false,
|
||||||
})
|
})
|
||||||
|
|
@ -603,6 +815,112 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
|
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
|
||||||
expect(strip).not.toBeNull();
|
expect(strip).not.toBeNull();
|
||||||
expect(strip?.querySelector('path[fill="#22c55e"]')).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();
|
expect(strip?.querySelector('path[stroke="#3b82f6"]')).not.toBeNull();
|
||||||
|
|
||||||
await act(async () => {
|
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 pendingSentAtMs = Date.parse('2026-04-08T12:02:00.000Z');
|
||||||
|
const pending = { forge: pendingSentAtMs };
|
||||||
const messages: InboxMessage[] = [
|
const messages: InboxMessage[] = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
messageId: 'user-send',
|
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', () => {
|
it('clears pending replies when the team lead answers through a visible lead thought', () => {
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,22 @@ describe('ScopeWarningBanner', () => {
|
||||||
|
|
||||||
await cleanup();
|
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,
|
DialogDescription,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} 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 host: HTMLDivElement;
|
||||||
let root: ReturnType<typeof createRoot>;
|
let root: ReturnType<typeof createRoot>;
|
||||||
|
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||||
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
host = document.createElement('div');
|
host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
root = createRoot(host);
|
root = createRoot(host);
|
||||||
|
|
@ -25,6 +35,7 @@ describe('DialogContent FocusScope integration', () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -52,4 +63,31 @@ describe('DialogContent FocusScope integration', () => {
|
||||||
|
|
||||||
expect(document.body.textContent).toContain('Create team updated');
|
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', () => {
|
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([
|
const current = createMultimodelStatus([
|
||||||
createMultimodelProvider({
|
createMultimodelProvider({
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -202,10 +202,11 @@ describe('cliInstallerSlice', () => {
|
||||||
|
|
||||||
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
||||||
{
|
{
|
||||||
supported: true,
|
supported: false,
|
||||||
authenticated: true,
|
authenticated: false,
|
||||||
authMethod: 'opencode_managed',
|
authMethod: null,
|
||||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
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([
|
const current = createMultimodelStatus([
|
||||||
createMultimodelProvider({
|
createMultimodelProvider({
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -336,8 +377,10 @@ describe('cliInstallerSlice', () => {
|
||||||
|
|
||||||
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
||||||
{
|
{
|
||||||
authenticated: true,
|
authenticated: false,
|
||||||
authMethod: 'opencode_managed',
|
authMethod: null,
|
||||||
|
verificationState: 'error',
|
||||||
|
statusMessage: 'OpenCode CLI not found',
|
||||||
models: ['opencode/minimax-m2.5-free'],
|
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', () => {
|
describe('OpenCode runtime installer actions', () => {
|
||||||
|
|
@ -706,7 +802,6 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||||
anthropic: false,
|
anthropic: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
|
||||||
opencode: false,
|
opencode: false,
|
||||||
});
|
});
|
||||||
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
|
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
|
||||||
|
|
@ -786,7 +881,6 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||||
anthropic: false,
|
anthropic: false,
|
||||||
codex: true,
|
codex: true,
|
||||||
gemini: false,
|
|
||||||
opencode: false,
|
opencode: false,
|
||||||
});
|
});
|
||||||
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1);
|
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
@ -806,7 +900,6 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||||
anthropic: false,
|
anthropic: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
|
||||||
opencode: false,
|
opencode: false,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -896,7 +989,6 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||||
anthropic: false,
|
anthropic: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
|
||||||
opencode: false,
|
opencode: false,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -983,6 +1075,72 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
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 () => {
|
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;
|
let resolveProviderStatus!: (value: CliInstallationStatus['providers'][number]) => void;
|
||||||
const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>(
|
const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>(
|
||||||
|
|
@ -1037,6 +1195,83 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
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 () => {
|
it('keeps OpenCode refresh status-only even when model verification is requested', async () => {
|
||||||
const nextProvider = createMultimodelProvider({
|
const nextProvider = createMultimodelProvider({
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
|
||||||
|
|
@ -4323,6 +4323,102 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
|
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 () => {
|
it('updates runtime snapshots when copy-diagnostics details change', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
const snapshot = createRuntimeSnapshot({
|
const snapshot = createRuntimeSnapshot({
|
||||||
|
|
|
||||||
|
|
@ -806,6 +806,31 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
).toContain('Anthropic authentication error');
|
).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', () => {
|
it('formats raw OpenCode protocol advisory reasons before showing them in titles', () => {
|
||||||
const advisory = {
|
const advisory = {
|
||||||
kind: 'api_error' as const,
|
kind: 'api_error' as const,
|
||||||
|
|
|
||||||
|
|
@ -90,4 +90,28 @@ describe('member launch diagnostics', () => {
|
||||||
);
|
);
|
||||||
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"');
|
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';
|
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.
|
// time which is unavailable in the vitest/happy-dom environment.
|
||||||
const sentryNoOp = {
|
const sentryNoOp = {
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
|
|
@ -17,6 +17,7 @@ const sentryNoOp = {
|
||||||
captureException: vi.fn(),
|
captureException: vi.fn(),
|
||||||
setUser: vi.fn(),
|
setUser: vi.fn(),
|
||||||
setTags: vi.fn(),
|
setTags: vi.fn(),
|
||||||
|
close: vi.fn(() => Promise.resolve(true)),
|
||||||
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
|
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
|
||||||
withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.fn() })),
|
withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.fn() })),
|
||||||
browserTracingIntegration: vi.fn(() => ({
|
browserTracingIntegration: vi.fn(() => ({
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ export default defineConfig({
|
||||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||||
'@preload': resolve(__dirname, 'src/preload'),
|
'@preload': resolve(__dirname, 'src/preload'),
|
||||||
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts'),
|
'@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: resolve(__dirname, 'node_modules/react'),
|
||||||
'react-dom': resolve(__dirname, 'node_modules/react-dom'),
|
'react-dom': resolve(__dirname, 'node_modules/react-dom'),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue