fix(updater): prevent installation of non-newer versions and enhance update notifications
- Added checks to ensure that only newer versions are installed during the update process. - Updated the notification logic to suppress alerts for non-newer updates. - Introduced a new method to compare version numbers, improving version management in the UpdaterService. - Enhanced the release workflow by removing unnecessary file uploads and adding canonical updater metadata publishing for better asset management.
This commit is contained in:
parent
e4c9a100f3
commit
f08885d58f
9 changed files with 362 additions and 32 deletions
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
|
|
@ -143,7 +143,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
for f in release/*.dmg release/*.zip release/*.blockmap release/*.yml; do
|
||||
for f in release/*.dmg release/*.zip release/*.blockmap; do
|
||||
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
done
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
for f in release/*.exe release/*.blockmap release/*.yml; do
|
||||
for f in release/*.exe release/*.blockmap; do
|
||||
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
done
|
||||
|
||||
|
|
@ -285,7 +285,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap release/*.yml; do
|
||||
for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap; do
|
||||
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
done
|
||||
|
||||
|
|
@ -326,3 +326,85 @@ jobs:
|
|||
gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber
|
||||
rm -f "$STABLE_NAME"
|
||||
done
|
||||
|
||||
- name: Publish canonical updater metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
TAG="v${VERSION}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
RELEASE_DATE="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cd "$TMP_DIR"
|
||||
|
||||
sha512_base64() {
|
||||
openssl dgst -sha512 -binary "$1" | openssl base64 -A
|
||||
}
|
||||
|
||||
file_size() {
|
||||
wc -c < "$1" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
download_asset() {
|
||||
local name="$1"
|
||||
curl -fSL -o "$name" "https://github.com/${REPO}/releases/download/${TAG}/${name}"
|
||||
}
|
||||
|
||||
# Canonical Windows feed
|
||||
download_asset "Claude-Agent-Teams-UI-Setup.exe"
|
||||
WIN_SHA="$(sha512_base64 Claude-Agent-Teams-UI-Setup.exe)"
|
||||
WIN_SIZE="$(file_size Claude-Agent-Teams-UI-Setup.exe)"
|
||||
cat > latest.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
- url: Claude-Agent-Teams-UI-Setup.exe
|
||||
sha512: ${WIN_SHA}
|
||||
size: ${WIN_SIZE}
|
||||
path: Claude-Agent-Teams-UI-Setup.exe
|
||||
sha512: ${WIN_SHA}
|
||||
releaseDate: '${RELEASE_DATE}'
|
||||
EOF
|
||||
|
||||
# Canonical Linux feed
|
||||
download_asset "Claude-Agent-Teams-UI.AppImage"
|
||||
LINUX_SHA="$(sha512_base64 Claude-Agent-Teams-UI.AppImage)"
|
||||
LINUX_SIZE="$(file_size Claude-Agent-Teams-UI.AppImage)"
|
||||
cat > latest-linux.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
- url: Claude-Agent-Teams-UI.AppImage
|
||||
sha512: ${LINUX_SHA}
|
||||
size: ${LINUX_SIZE}
|
||||
path: Claude-Agent-Teams-UI.AppImage
|
||||
sha512: ${LINUX_SHA}
|
||||
releaseDate: '${RELEASE_DATE}'
|
||||
EOF
|
||||
|
||||
# Canonical macOS feed.
|
||||
# electron-updater consumes only one latest-mac.yml asset on GitHub releases.
|
||||
# We publish the x64 feed here because it works on Intel Macs and remains
|
||||
# installable on Apple Silicon via Rosetta until we add arch-specific feed
|
||||
# selection or universal packaging.
|
||||
download_asset "Claude.Agent.Teams.UI-${VERSION}-mac.zip"
|
||||
download_asset "Claude.Agent.Teams.UI-${VERSION}.dmg"
|
||||
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-mac.zip)"
|
||||
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-mac.zip)"
|
||||
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}.dmg)"
|
||||
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}.dmg)"
|
||||
cat > latest-mac.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
- url: Claude.Agent.Teams.UI-${VERSION}-mac.zip
|
||||
sha512: ${MAC_ZIP_SHA}
|
||||
size: ${MAC_ZIP_SIZE}
|
||||
- url: Claude.Agent.Teams.UI-${VERSION}.dmg
|
||||
sha512: ${MAC_DMG_SHA}
|
||||
size: ${MAC_DMG_SIZE}
|
||||
path: Claude.Agent.Teams.UI-${VERSION}-mac.zip
|
||||
sha512: ${MAC_ZIP_SHA}
|
||||
releaseDate: '${RELEASE_DATE}'
|
||||
EOF
|
||||
|
||||
gh release upload "${TAG}" latest.yml latest-linux.yml latest-mac.yml --repo "${REPO}" --clobber
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@
|
|||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||
import electronUpdater from 'electron-updater';
|
||||
|
||||
const { autoUpdater } = electronUpdater;
|
||||
|
||||
import { net } from 'electron';
|
||||
import { app, net } from 'electron';
|
||||
|
||||
import type { UpdaterStatus } from '@shared/types';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
|
@ -64,6 +65,7 @@ async function assetExists(url: string): Promise<boolean> {
|
|||
export class UpdaterService {
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private periodicTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private downloadedVersion: string | null = null;
|
||||
|
||||
constructor() {
|
||||
autoUpdater.autoDownload = false;
|
||||
|
|
@ -109,6 +111,17 @@ export class UpdaterService {
|
|||
* isForceRunAfter=true launches the app after install. Other platforms ignore these.
|
||||
*/
|
||||
quitAndInstall(): void {
|
||||
if (!this.downloadedVersion || !this.isNewerThanCurrent(this.downloadedVersion)) {
|
||||
logger.warn(
|
||||
`Refusing to install non-newer update. current=${app.getVersion()} downloaded=${this.downloadedVersion ?? 'unknown'}`
|
||||
);
|
||||
this.sendStatus({
|
||||
type: 'error',
|
||||
error: 'Refused to install a non-newer app version.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +150,10 @@ export class UpdaterService {
|
|||
safeSendToRenderer(this.mainWindow, 'updater:status', status);
|
||||
}
|
||||
|
||||
private isNewerThanCurrent(candidateVersion: string): boolean {
|
||||
return isVersionOlder(normalizeVersion(app.getVersion()), normalizeVersion(candidateVersion));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the platform-specific asset exists before notifying the renderer.
|
||||
* If CI hasn't finished uploading the artifact for this OS yet, suppress the
|
||||
|
|
@ -146,6 +163,13 @@ export class UpdaterService {
|
|||
version: string;
|
||||
releaseNotes?: string | unknown;
|
||||
}): Promise<void> {
|
||||
if (!this.isNewerThanCurrent(info.version)) {
|
||||
logger.warn(
|
||||
`Suppressing non-newer update notification. current=${app.getVersion()} candidate=${info.version}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getExpectedAssetUrl(info.version);
|
||||
if (url) {
|
||||
const exists = await assetExists(url);
|
||||
|
|
@ -192,6 +216,14 @@ export class UpdaterService {
|
|||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
if (!this.isNewerThanCurrent(info.version)) {
|
||||
logger.warn(
|
||||
`Ignoring downloaded non-newer update. current=${app.getVersion()} downloaded=${info.version}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.downloadedVersion = info.version;
|
||||
logger.info('Update downloaded:', info.version);
|
||||
this.sendStatus({
|
||||
type: 'downloaded',
|
||||
|
|
|
|||
|
|
@ -1695,6 +1695,28 @@ export class TeamProvisioningService {
|
|||
return Array.isArray(innerContent) ? (innerContent as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
private hasCapturedVisibleMessageToUser(content: Record<string, unknown>[]): boolean {
|
||||
return content.some((part) => {
|
||||
if (!part || typeof part !== 'object') return false;
|
||||
if (part.type !== 'tool_use' || typeof part.name !== 'string') return false;
|
||||
|
||||
// Only native SendMessage(to="user") is guaranteed to be materialized as a
|
||||
// visible outbound message by captureSendMessages().
|
||||
// Keep this intentionally narrower than captureSendMessages(): if another tool path
|
||||
// later starts creating its own user-visible row, expand this helper in lockstep.
|
||||
if (part.name !== 'SendMessage') return false;
|
||||
|
||||
const input = part.input;
|
||||
if (!input || typeof input !== 'object') return false;
|
||||
const inp = input as Record<string, unknown>;
|
||||
const target = (
|
||||
typeof inp.recipient === 'string' ? inp.recipient : typeof inp.to === 'string' ? inp.to : ''
|
||||
).trim();
|
||||
|
||||
return target.toLowerCase() === 'user';
|
||||
});
|
||||
}
|
||||
|
||||
private async matchCrossTeamLeadInboxMessages(
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
|
|
@ -5142,14 +5164,7 @@ export class TeamProvisioningService {
|
|||
if (msg.type === 'assistant') {
|
||||
const content = this.extractStreamContentBlocks(msg);
|
||||
|
||||
const hasCapturedSendMessage = content.some((part) => {
|
||||
if (!part || typeof part !== 'object') return false;
|
||||
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
|
||||
const input = part.input;
|
||||
if (!input || typeof input !== 'object') return false;
|
||||
const recipient = (input as Record<string, unknown>).recipient;
|
||||
return typeof recipient === 'string' && recipient.trim().length > 0;
|
||||
});
|
||||
const hasCapturedVisibleMessageToUser = this.hasCapturedVisibleMessageToUser(content);
|
||||
|
||||
const textParts = content
|
||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||
|
|
@ -5170,26 +5185,27 @@ export class TeamProvisioningService {
|
|||
run.provisioningOutputParts.push(text);
|
||||
}
|
||||
|
||||
if (run.leadRelayCapture) {
|
||||
// Once relay capture is settled, later assistant chunks belong to the normal live
|
||||
// message flow. Keeping them in the capture branch would drop them on the floor
|
||||
// until relayLeadInboxMessages() finally clears run.leadRelayCapture.
|
||||
if (run.leadRelayCapture && !run.leadRelayCapture.settled) {
|
||||
const capture = run.leadRelayCapture;
|
||||
if (!capture.settled) {
|
||||
capture.textParts.push(text);
|
||||
if (capture.idleHandle) {
|
||||
clearTimeout(capture.idleHandle);
|
||||
}
|
||||
capture.idleHandle = setTimeout(() => {
|
||||
const combined = capture.textParts.join('\n').trim();
|
||||
capture.resolveOnce(combined);
|
||||
}, capture.idleMs);
|
||||
capture.textParts.push(text);
|
||||
if (capture.idleHandle) {
|
||||
clearTimeout(capture.idleHandle);
|
||||
}
|
||||
capture.idleHandle = setTimeout(() => {
|
||||
const combined = capture.textParts.join('\n').trim();
|
||||
capture.resolveOnce(combined);
|
||||
}, capture.idleMs);
|
||||
} else if (run.provisioningComplete) {
|
||||
// Push each assistant text block as a separate live message (per-message pattern).
|
||||
// When the same assistant message includes SendMessage(...), skip text —
|
||||
// When the same assistant message includes a user-visible message send, skip text —
|
||||
// captureSendMessages() handles the visible outbound message separately.
|
||||
if (
|
||||
!run.silentUserDmForward &&
|
||||
!run.suppressPostCompactReminderOutput &&
|
||||
!hasCapturedSendMessage
|
||||
!hasCapturedVisibleMessageToUser
|
||||
) {
|
||||
const cleanText = stripAgentBlocks(text).trim();
|
||||
if (cleanText.length > 0) {
|
||||
|
|
@ -5203,7 +5219,7 @@ export class TeamProvisioningService {
|
|||
} else {
|
||||
// Pre-ready: keep showing provisioning narration in the banner, but also mirror it
|
||||
// into the live cache so Messages/Activity can show the earliest assistant output.
|
||||
if (!run.silentUserDmForward && !hasCapturedSendMessage) {
|
||||
if (!run.silentUserDmForward && !hasCapturedVisibleMessageToUser) {
|
||||
const cleanText = stripAgentBlocks(text).trim();
|
||||
if (cleanText.length > 0) {
|
||||
this.pushLiveLeadTextMessage(
|
||||
|
|
@ -6530,6 +6546,15 @@ export class TeamProvisioningService {
|
|||
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
||||
|
||||
// Force a post-ready detail refresh so Messages reload persisted lead_session
|
||||
// texts from JSONL even if the last visible assistant output only reached disk.
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'lead-message',
|
||||
teamName: run.teamName,
|
||||
runId: run.runId,
|
||||
detail: 'lead-session-sync',
|
||||
});
|
||||
|
||||
// Fire "Team Launched" notification
|
||||
void this.fireTeamLaunchedNotification(run);
|
||||
|
||||
|
|
@ -6629,6 +6654,15 @@ export class TeamProvisioningService {
|
|||
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
||||
|
||||
// Force a post-ready detail refresh so Messages reload persisted lead_session
|
||||
// texts from JSONL even if the last visible assistant output only reached disk.
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'lead-message',
|
||||
teamName: run.teamName,
|
||||
runId: run.runId,
|
||||
detail: 'lead-session-sync',
|
||||
});
|
||||
|
||||
// Fire "Team Launched" notification
|
||||
void this.fireTeamLaunchedNotification(run);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { api } from '@renderer/api';
|
|||
import { syncRendererTelemetry } from '@renderer/sentry';
|
||||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||
import {
|
||||
buildTaskChangePresenceKey,
|
||||
buildTaskChangeRequestOptions,
|
||||
|
|
@ -53,6 +54,8 @@ const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
|
|||
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
|
||||
const FINISHED_TOOL_DISPLAY_MS = 1_500;
|
||||
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
|
||||
const CURRENT_APP_VERSION =
|
||||
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
||||
|
||||
// =============================================================================
|
||||
// Store Creation
|
||||
|
|
@ -1199,12 +1202,16 @@ export function initializeNotificationListeners(): () => void {
|
|||
if (currentStatus === 'downloading' || currentStatus === 'downloaded') {
|
||||
break;
|
||||
}
|
||||
const nextVersion = s.version ? normalizeVersion(s.version) : null;
|
||||
if (!nextVersion || !isVersionOlder(CURRENT_APP_VERSION, nextVersion)) {
|
||||
break;
|
||||
}
|
||||
const dismissed = useStore.getState().dismissedUpdateVersion;
|
||||
useStore.setState({
|
||||
updateStatus: 'available',
|
||||
availableVersion: s.version ?? null,
|
||||
availableVersion: nextVersion,
|
||||
releaseNotes: s.releaseNotes ?? null,
|
||||
showUpdateDialog: (s.version ?? null) !== dismissed,
|
||||
showUpdateDialog: nextVersion !== dismissed,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -1223,10 +1230,15 @@ export function initializeNotificationListeners(): () => void {
|
|||
});
|
||||
break;
|
||||
case 'downloaded':
|
||||
if (s.version && !isVersionOlder(CURRENT_APP_VERSION, normalizeVersion(s.version))) {
|
||||
break;
|
||||
}
|
||||
useStore.setState({
|
||||
updateStatus: 'downloaded',
|
||||
downloadProgress: 100,
|
||||
availableVersion: s.version ?? useStore.getState().availableVersion,
|
||||
availableVersion: s.version
|
||||
? normalizeVersion(s.version)
|
||||
: useStore.getState().availableVersion,
|
||||
});
|
||||
break;
|
||||
case 'error': {
|
||||
|
|
|
|||
2
src/renderer/vite-env.d.ts
vendored
2
src/renderer/vite-env.d.ts
vendored
|
|
@ -1,5 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
// eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports
|
||||
|
|
|
|||
29
src/shared/utils/version.ts
Normal file
29
src/shared/utils/version.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Extract semver-like version from strings such as "v1.2.3" or "1.2.3 (beta)".
|
||||
*/
|
||||
export function normalizeVersion(raw: string): string {
|
||||
const match = /\d{1,10}\.\d{1,10}\.\d{1,10}/.exec(raw);
|
||||
return match ? match[0] : raw.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric semver comparison.
|
||||
* Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const aParts = normalizeVersion(a).split('.').map(Number);
|
||||
const bParts = normalizeVersion(b).split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const left = aParts[i] ?? 0;
|
||||
const right = bParts[i] ?? 0;
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isVersionOlder(installed: string, latest: string): boolean {
|
||||
return compareVersions(installed, latest) < 0;
|
||||
}
|
||||
|
|
@ -332,7 +332,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
|||
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('captures SendMessage(to:team-lead) without rendering duplicate assistant thought text', () => {
|
||||
it('keeps assistant thought text when SendMessage targets a teammate', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||
|
|
@ -355,15 +355,48 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
|||
});
|
||||
|
||||
const live = service.getLiveLeadProcessMessages('my-team');
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].to).toBe('team-lead');
|
||||
expect(live[0].text).toBe('Need clarification on #abcd1234');
|
||||
expect(live).toHaveLength(2);
|
||||
expect(live[0].to).toBeUndefined();
|
||||
expect(live[0].text).toBe('Forwarding the clarification request now.');
|
||||
expect(live[0].source).toBe('lead_process');
|
||||
expect(live[1].to).toBe('team-lead');
|
||||
expect(live[1].text).toBe('Need clarification on #abcd1234');
|
||||
expect(live[1].source).toBe('lead_process');
|
||||
// Non-user recipient → delivered to inbox, not sentMessages
|
||||
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses duplicate assistant thought text when SendMessage targets user', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||
|
||||
callHandleStreamJsonMessage(service, run, {
|
||||
type: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Task completed. Sending the summary now.' },
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'SendMessage',
|
||||
input: {
|
||||
type: 'message',
|
||||
recipient: 'user',
|
||||
content: 'Task completed successfully.',
|
||||
summary: 'Done',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const live = service.getLiveLeadProcessMessages('my-team');
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].to).toBe('user');
|
||||
expect(live[0].text).toBe('Task completed successfully.');
|
||||
expect(live[0].source).toBe('lead_process');
|
||||
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('post-ready path also uses the unified helper', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
|
|
|
|||
|
|
@ -223,4 +223,61 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a lead-message refresh after provisioning reaches ready', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const emitter = vi.fn();
|
||||
svc.setTeamChangeEmitter(emitter);
|
||||
|
||||
const run = {
|
||||
runId: 'run-3',
|
||||
teamName: 'team-alpha',
|
||||
request: {
|
||||
cwd: tempRoot,
|
||||
color: 'blue',
|
||||
members: [{ name: 'dev', role: 'engineer' }],
|
||||
},
|
||||
progress: {
|
||||
runId: 'run-3',
|
||||
teamName: 'team-alpha',
|
||||
state: 'assembling',
|
||||
message: 'Assembling',
|
||||
startedAt: '2026-03-12T10:00:00.000Z',
|
||||
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||
},
|
||||
provisioningComplete: false,
|
||||
cancelRequested: false,
|
||||
processKilled: false,
|
||||
stdoutBuffer: '',
|
||||
stdoutLogLineBuf: '',
|
||||
stdoutParserCarry: '',
|
||||
stdoutParserCarryIsCompleteJson: false,
|
||||
stdoutParserCarryLooksLikeClaudeJson: false,
|
||||
stderrBuffer: '',
|
||||
stderrLogLineBuf: '',
|
||||
provisioningOutputParts: [],
|
||||
onProgress: vi.fn(),
|
||||
isLaunch: true,
|
||||
detectedSessionId: null,
|
||||
timeoutHandle: null,
|
||||
fsMonitorHandle: null,
|
||||
claudeLogLines: [],
|
||||
activeToolCalls: new Map(),
|
||||
leadActivityState: 'active',
|
||||
leadContextUsage: null,
|
||||
};
|
||||
|
||||
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
|
||||
|
||||
await (svc as any).handleProvisioningTurnComplete(run);
|
||||
|
||||
expect(emitter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'lead-message',
|
||||
teamName: 'team-alpha',
|
||||
runId: 'run-3',
|
||||
detail: 'lead-session-sync',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -257,6 +257,55 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows assistant text after relay capture has already settled', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
attachAliveRun(service, teamName);
|
||||
|
||||
const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as {
|
||||
leadRelayCapture: {
|
||||
leadName: string;
|
||||
startedAt: string;
|
||||
textParts: string[];
|
||||
settled: boolean;
|
||||
idleHandle: NodeJS.Timeout | null;
|
||||
idleMs: number;
|
||||
resolveOnce: (text: string) => void;
|
||||
rejectOnce: (error: string) => void;
|
||||
timeoutHandle: NodeJS.Timeout;
|
||||
} | null;
|
||||
};
|
||||
|
||||
run.leadRelayCapture = {
|
||||
leadName: 'team-lead',
|
||||
startedAt: new Date().toISOString(),
|
||||
textParts: [],
|
||||
settled: true,
|
||||
idleHandle: null,
|
||||
idleMs: 800,
|
||||
resolveOnce: vi.fn(),
|
||||
rejectOnce: vi.fn(),
|
||||
timeoutHandle: setTimeout(() => undefined, 60_000),
|
||||
};
|
||||
|
||||
try {
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Late reply after relay completion.' }],
|
||||
});
|
||||
|
||||
const live = service.getLiveLeadProcessMessages(teamName);
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].to).toBeUndefined();
|
||||
expect(live[0].text).toBe('Late reply after relay completion.');
|
||||
expect(live[0].source).toBe('lead_process');
|
||||
} finally {
|
||||
clearTimeout(run.leadRelayCapture.timeoutHandle);
|
||||
run.leadRelayCapture = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('adds task-first reply guidance for task comment notifications in lead relay prompts', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
Loading…
Reference in a new issue