feat: enhance team provisioning and messaging components
- Improved post-compact reminder handling in TeamProvisioningService to preserve pending reminders during compact boundaries and ensure proper state management on errors. - Updated SendMessageDialog to remove the summary field, simplifying the message submission process and ensuring the message body is used consistently. - Enhanced MemberCard styling with a gradient background for better visual appeal. - Adjusted MemberList layout to dynamically adapt based on sidebar state, improving user experience in member display. - Added tests to validate new reminder logic and ensure live config updates are reflected in reminders.
This commit is contained in:
parent
748d6a7b81
commit
f88fa9cb09
6 changed files with 173 additions and 60 deletions
|
|
@ -49,7 +49,7 @@ A new approach to task management with AI agents.
|
||||||
- **Attach code context** — reference files or snippets in messages, like in Cursor
|
- **Attach code context** — reference files or snippets in messages, like in Cursor
|
||||||
- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
|
- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
|
||||||
- **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box
|
- **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box
|
||||||
|
- **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
|
||||||
|
|
@ -3163,9 +3163,16 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
if (run.provisioningComplete) {
|
if (run.provisioningComplete) {
|
||||||
// If this was a post-compact reminder turn completing, clear in-flight and suppress flags.
|
// If this was a post-compact reminder turn completing, clear in-flight and suppress flags.
|
||||||
|
// Preserve pendingPostCompactReminder if re-armed by a compact_boundary during this turn.
|
||||||
if (run.postCompactReminderInFlight) {
|
if (run.postCompactReminderInFlight) {
|
||||||
clearPostCompactReminderState(run);
|
const hadPendingRearm = run.pendingPostCompactReminder;
|
||||||
logger.info(`[${run.teamName}] post-compact reminder turn completed`);
|
run.postCompactReminderInFlight = false;
|
||||||
|
run.suppressPostCompactReminderOutput = false;
|
||||||
|
logger.info(
|
||||||
|
`[${run.teamName}] post-compact reminder turn completed${
|
||||||
|
hadPendingRearm ? ' (follow-up reminder pending from re-compact)' : ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setLeadActivity(run, 'idle');
|
this.setLeadActivity(run, 'idle');
|
||||||
|
|
@ -3227,11 +3234,13 @@ export class TeamProvisioningService {
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
} else if (run.provisioningComplete) {
|
} else if (run.provisioningComplete) {
|
||||||
// Post-provisioning error: process alive, waiting for input.
|
// Post-provisioning error: process alive, waiting for input.
|
||||||
// Drop post-compact reminder on error (strict drop-after-attempt policy).
|
// Always clear all post-compact reminder state on error — prevents a stale pending
|
||||||
if (run.postCompactReminderInFlight) {
|
// reminder from firing on the next unrelated successful turn.
|
||||||
|
if (run.pendingPostCompactReminder || run.postCompactReminderInFlight) {
|
||||||
|
const wasInFlight = run.postCompactReminderInFlight;
|
||||||
clearPostCompactReminderState(run);
|
clearPostCompactReminderState(run);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${run.teamName}] post-compact reminder turn errored — dropping (strict policy)`
|
`[${run.teamName}] post-compact reminder ${wasInFlight ? 'turn errored' : 'pending dropped'} — clearing (strict policy)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.setLeadActivity(run, 'idle');
|
this.setLeadActivity(run, 'idle');
|
||||||
|
|
@ -3273,14 +3282,15 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schedule post-compact context reinjection on next idle.
|
// Schedule post-compact context reinjection on next idle.
|
||||||
// Guard: only set if provisioning is complete and no reminder is already pending/in-flight.
|
// If a reminder is already in-flight, re-arm pending so a follow-up fires after it completes.
|
||||||
if (
|
// This handles the case where the reminder prompt itself triggers another compaction.
|
||||||
run.provisioningComplete &&
|
if (run.provisioningComplete && !run.pendingPostCompactReminder) {
|
||||||
!run.pendingPostCompactReminder &&
|
|
||||||
!run.postCompactReminderInFlight
|
|
||||||
) {
|
|
||||||
run.pendingPostCompactReminder = true;
|
run.pendingPostCompactReminder = true;
|
||||||
logger.info(`[${run.teamName}] post-compact reminder scheduled for next idle`);
|
logger.info(
|
||||||
|
`[${run.teamName}] post-compact reminder scheduled for next idle${
|
||||||
|
run.postCompactReminderInFlight ? ' (re-armed during in-flight reminder)' : ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3333,16 +3343,43 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leadName =
|
// Read current team config for up-to-date members (may have changed since launch).
|
||||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
let currentMembers: TeamCreateRequest['members'] = run.request.members;
|
||||||
const isSolo = run.request.members.length === 0;
|
let leadName = 'team-lead';
|
||||||
|
try {
|
||||||
|
const config = await this.configReader.getConfig(run.teamName);
|
||||||
|
if (config?.members) {
|
||||||
|
const configLead = config.members.find((m) => m?.agentType === 'team-lead');
|
||||||
|
leadName = configLead?.name?.trim() || 'team-lead';
|
||||||
|
// Convert config members (excluding lead) to TeamCreateRequest member format.
|
||||||
|
currentMembers = config.members
|
||||||
|
.filter((m) => m?.agentType !== 'team-lead' && m?.name)
|
||||||
|
.map((m) => ({
|
||||||
|
name: m.name!,
|
||||||
|
role: m.role ?? undefined,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
leadName =
|
||||||
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||||
|
'team-lead';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to launch-time members if config is unavailable.
|
||||||
|
leadName =
|
||||||
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||||
|
'team-lead';
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] post-compact reminder: config unavailable, using launch-time members`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isSolo = currentMembers.length === 0;
|
||||||
|
|
||||||
// Build persistent lead context.
|
// Build persistent lead context.
|
||||||
const persistentContext = buildPersistentLeadContext({
|
const persistentContext = buildPersistentLeadContext({
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
leadName,
|
leadName,
|
||||||
isSolo,
|
isSolo,
|
||||||
members: run.request.members,
|
members: currentMembers,
|
||||||
compact: true,
|
compact: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
|
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
|
||||||
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
|
|
@ -86,7 +83,6 @@ export const SendMessageDialog = ({
|
||||||
const [member, setMember] = useState('');
|
const [member, setMember] = useState('');
|
||||||
const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` });
|
const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` });
|
||||||
const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`);
|
const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`);
|
||||||
const [summary, setSummary] = useState('');
|
|
||||||
const prevOpenRef = useRef(false);
|
const prevOpenRef = useRef(false);
|
||||||
const prevResultRef = useRef<SendMessageResult | null>(null);
|
const prevResultRef = useRef<SendMessageResult | null>(null);
|
||||||
|
|
||||||
|
|
@ -115,7 +111,6 @@ export const SendMessageDialog = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !prevOpenRef.current) {
|
if (open && !prevOpenRef.current) {
|
||||||
setMember(defaultRecipient ?? '');
|
setMember(defaultRecipient ?? '');
|
||||||
setSummary('');
|
|
||||||
setQuote(quotedMessage);
|
setQuote(quotedMessage);
|
||||||
setQuoteExpanded(false);
|
setQuoteExpanded(false);
|
||||||
prevResultRef.current = lastResult;
|
prevResultRef.current = lastResult;
|
||||||
|
|
@ -145,7 +140,6 @@ export const SendMessageDialog = ({
|
||||||
if (lastResult && lastResult !== prevResultRef.current) {
|
if (lastResult && lastResult !== prevResultRef.current) {
|
||||||
prevResultRef.current = lastResult;
|
prevResultRef.current = lastResult;
|
||||||
setMember('');
|
setMember('');
|
||||||
setSummary('');
|
|
||||||
setPendingAutoClose(true);
|
setPendingAutoClose(true);
|
||||||
}
|
}
|
||||||
}, [open, lastResult]);
|
}, [open, lastResult]);
|
||||||
|
|
@ -200,15 +194,7 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
// TODO: Research whether duplicating message as summary is correct — the team lead
|
onSend(member.trim(), finalText, trimmedText, attachments.length > 0 ? attachments : undefined);
|
||||||
// may only see the Summary field and not the full Message body. Need to verify.
|
|
||||||
const effectiveSummary = summary.trim() || trimmedText;
|
|
||||||
onSend(
|
|
||||||
member.trim(),
|
|
||||||
finalText,
|
|
||||||
effectiveSummary,
|
|
||||||
attachments.length > 0 ? attachments : undefined
|
|
||||||
);
|
|
||||||
textDraft.clearDraft();
|
textDraft.clearDraft();
|
||||||
chipDraft.clearChipDraft();
|
chipDraft.clearChipDraft();
|
||||||
clearAttachments();
|
clearAttachments();
|
||||||
|
|
@ -269,7 +255,7 @@ export const SendMessageDialog = ({
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-[720px]"
|
className="min-w-0 sm:max-w-4xl"
|
||||||
onDragEnter={canAttach ? handleDragEnter : undefined}
|
onDragEnter={canAttach ? handleDragEnter : undefined}
|
||||||
onDragLeave={canAttach ? handleDragLeave : undefined}
|
onDragLeave={canAttach ? handleDragLeave : undefined}
|
||||||
onDragOver={canAttach ? handleDragOver : undefined}
|
onDragOver={canAttach ? handleDragOver : undefined}
|
||||||
|
|
@ -440,27 +426,7 @@ export const SendMessageDialog = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="smd-summary">Summary</Label>
|
|
||||||
<Input
|
|
||||||
id="smd-summary"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
placeholder="Brief summary reflecting the message intent"
|
|
||||||
value={summary}
|
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
Shown as notification preview. Team lead also sees this for peer messages.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" size="sm" onClick={onClose} disabled={sending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export const MemberCard = ({
|
||||||
className="group relative cursor-pointer rounded px-2 py-1.5"
|
className="group relative cursor-pointer rounded px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
backgroundColor: colors.badge,
|
background: `linear-gradient(to right, ${colors.badge}, transparent)`,
|
||||||
}}
|
}}
|
||||||
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
||||||
role="button"
|
role="button"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
|
|
@ -32,6 +33,8 @@ export const MemberList = ({
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
}: MemberListProps): React.JSX.Element => {
|
}: MemberListProps): React.JSX.Element => {
|
||||||
|
const sidebarCollapsed = useStore((s) => s.sidebarCollapsed);
|
||||||
|
const gridClass = sidebarCollapsed ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
|
||||||
const activeMembers = members
|
const activeMembers = members
|
||||||
.filter((m) => !m.removedAt)
|
.filter((m) => !m.removedAt)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|
@ -75,14 +78,16 @@ export const MemberList = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-1">
|
||||||
{activeMembers.map((member) => renderCard(member, false))}
|
<div className={gridClass}>{activeMembers.map((member) => renderCard(member, false))}</div>
|
||||||
{removedMembers.length > 0 && (
|
{removedMembers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
Removed ({removedMembers.length})
|
Removed ({removedMembers.length})
|
||||||
</div>
|
</div>
|
||||||
{removedMembers.map((member) => renderCard(member, true))}
|
<div className={gridClass}>
|
||||||
|
{removedMembers.map((member) => renderCard(member, true))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ describe('TeamProvisioningService post-compact lifecycle', () => {
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('compact_boundary does NOT set pending when reminder is already in-flight', async () => {
|
it('compact_boundary re-arms pending when reminder is already in-flight', async () => {
|
||||||
const { svc, run, runId } = await setupRunningTeam('compact-test-3');
|
const { svc, run, runId } = await setupRunningTeam('compact-test-3');
|
||||||
run.postCompactReminderInFlight = true;
|
run.postCompactReminderInFlight = true;
|
||||||
|
|
||||||
|
|
@ -165,8 +165,8 @@ describe('TeamProvisioningService post-compact lifecycle', () => {
|
||||||
compact_metadata: { trigger: 'auto' },
|
compact_metadata: { trigger: 'auto' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should NOT be set because in-flight
|
// Should be re-armed even during in-flight — follow-up reminder after current completes
|
||||||
expect(run.pendingPostCompactReminder).toBe(false);
|
expect(run.pendingPostCompactReminder).toBe(true);
|
||||||
|
|
||||||
run.postCompactReminderInFlight = false;
|
run.postCompactReminderInFlight = false;
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
|
|
@ -365,4 +365,109 @@ describe('TeamProvisioningService post-compact lifecycle', () => {
|
||||||
// Should NOT re-arm pending (strict drop)
|
// Should NOT re-arm pending (strict drop)
|
||||||
expect(run.pendingPostCompactReminder).toBe(false);
|
expect(run.pendingPostCompactReminder).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('result.error clears pending even when NOT in-flight (no stale pending survives)', async () => {
|
||||||
|
const { svc, run } = await setupRunningTeam('compact-test-13');
|
||||||
|
// pending set but reminder never started (no in-flight)
|
||||||
|
run.pendingPostCompactReminder = true;
|
||||||
|
run.postCompactReminderInFlight = false;
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
(svc as any).handleStreamJsonMessage(run, {
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'error',
|
||||||
|
error: 'some error',
|
||||||
|
});
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
|
||||||
|
// Pending must be cleared — must not fire on a later unrelated result.success
|
||||||
|
expect(run.pendingPostCompactReminder).toBe(false);
|
||||||
|
expect(run.postCompactReminderInFlight).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compact_boundary during in-flight produces follow-up reminder after current completes', async () => {
|
||||||
|
const { svc, run, runId, writeSpy } = await setupRunningTeam('compact-test-14');
|
||||||
|
|
||||||
|
// Start first reminder
|
||||||
|
run.pendingPostCompactReminder = true;
|
||||||
|
writeSpy.mockClear();
|
||||||
|
await (svc as any).injectPostCompactReminder(run);
|
||||||
|
expect(run.postCompactReminderInFlight).toBe(true);
|
||||||
|
expect(run.pendingPostCompactReminder).toBe(false);
|
||||||
|
|
||||||
|
// Compact fires while first reminder is in-flight
|
||||||
|
(svc as any).handleStreamJsonMessage(run, {
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'compact_boundary',
|
||||||
|
compact_metadata: { trigger: 'auto' },
|
||||||
|
});
|
||||||
|
// Re-armed
|
||||||
|
expect(run.pendingPostCompactReminder).toBe(true);
|
||||||
|
|
||||||
|
// First reminder completes (result.success).
|
||||||
|
// The success handler clears in-flight, preserves pending, transitions to idle,
|
||||||
|
// then the injection hook fires immediately because pending=true && !inFlight.
|
||||||
|
// So after success, a NEW reminder is already in-flight.
|
||||||
|
writeSpy.mockClear();
|
||||||
|
(svc as any).handleStreamJsonMessage(run, {
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'success',
|
||||||
|
result: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow the void async injection to run
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
// A follow-up reminder was triggered: in-flight again, pending consumed
|
||||||
|
expect(run.postCompactReminderInFlight).toBe(true);
|
||||||
|
expect(run.pendingPostCompactReminder).toBe(false);
|
||||||
|
// Verify a second write happened (the follow-up reminder)
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reminder reads live config.json members instead of stale launch-time members', async () => {
|
||||||
|
const { svc, run, runId, writeSpy } = await setupRunningTeam('compact-test-15');
|
||||||
|
|
||||||
|
// Original launch had only alice
|
||||||
|
run.request.members = [{ name: 'alice', role: 'developer' }];
|
||||||
|
|
||||||
|
// Mock configReader.getConfig to return updated team with alice + bob
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: 'compact-test-15',
|
||||||
|
description: 'Test team',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||||
|
{ name: 'bob', agentType: 'teammate', role: 'tester' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
run.pendingPostCompactReminder = true;
|
||||||
|
writeSpy.mockClear();
|
||||||
|
await (svc as any).injectPostCompactReminder(run);
|
||||||
|
|
||||||
|
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||||
|
const parsed = JSON.parse(payload) as {
|
||||||
|
type: string;
|
||||||
|
message?: { role: string; content: { type: string; text?: string }[] };
|
||||||
|
};
|
||||||
|
const text = parsed.message?.content?.[0]?.text ?? '';
|
||||||
|
|
||||||
|
// Should contain bob from live config, not just alice from launch-time
|
||||||
|
expect(text).toContain('bob');
|
||||||
|
expect(text).toContain('alice');
|
||||||
|
// Should NOT be in solo mode — check for the actual solo constraint block
|
||||||
|
expect(text).not.toContain('SOLO MODE: This team CURRENTLY has ZERO teammates');
|
||||||
|
// Members section should include both
|
||||||
|
expect(text).toContain('- alice (developer)');
|
||||||
|
expect(text).toContain('- bob (tester)');
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue