Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"smoke:goal-acceptance-contract-packaged": "LOOPX_ACCEPTANCE_CONTRACT_PACKAGED=1 LOOPX_ACCEPTANCE_CONTRACT_PORT=5297 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-acceptance-contract-browser-smoke.mjs",
"smoke:status-projection-contract": "rm -rf /tmp/loopx-status-projection-contract-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-projection-contract-smoke smoke/status-projection-contract-smoke.ts src/data/status.ts src/data/status-merge.ts src/data/status-request-fence.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-projection-contract-smoke/apps/presentation/dashboard/smoke/status-projection-contract-smoke.js",
"smoke:team-plan-recovery": "vite build --ssr smoke/team-plan-recovery-smoke.mjs --outDir node_modules/.cache/loopx-team-plan-recovery --emptyOutDir && node node_modules/.cache/loopx-team-plan-recovery/team-plan-recovery-smoke.js",
"smoke:multi-recipient": "node smoke/multi-recipient-browser-smoke.mjs",
"smoke:team-plan-proposal": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --jsx react-jsx --types node --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-team-plan-smoke smoke/team-plan-proposal-smoke.ts src/data/chat.ts src/features/personal-workspace/team-plan-preview.ts src/vite-env.d.ts && node node_modules/.cache/loopx-team-plan-smoke/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.js",
"smoke:status-source-switch-browser": "node ../../../examples/status-source-switch-browser-smoke.mjs",
"smoke:status-source-switch-packaged": "LOOPX_STATUS_SOURCE_SWITCH_PACKAGED=1 LOOPX_STATUS_SOURCE_SWITCH_PORT=5198 node ../../../examples/status-source-switch-browser-smoke.mjs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// One request, independent recipient states and returns over the packaged real backend.
import assert from 'node:assert/strict';
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {mkdir} from 'node:fs/promises';
import {resolve} from 'node:path';
import {createInterface} from 'node:readline';
import {testPythonCommand} from '../../../../scripts/test-python.mjs';
import {launchBrowser, loadPlaywright} from '../../../../examples/dashboard-browser-smoke-support.mjs';
const repo = resolve(import.meta.dirname, '../../../..');
const output = resolve(repo, 'output/playwright/multi-recipient');
await mkdir(output, {recursive:true});
const fixturePath = resolve(repo, 'examples/personal-workspace-browser/steward_requests_fixture.py');
const command = testPythonCommand(`import runpy; runpy.run_path(${JSON.stringify(fixturePath)}, run_name="__main__")`, {repoRoot:repo});
const child = spawn(command.command, [...command.args, '--status-file', resolve(repo, 'examples/status.example.json'),
'--multi-recipient'], {cwd:repo, env:{...process.env, LOOPX_USAGE_PING:'0'}, stdio:['pipe','pipe','pipe']});
const exited = once(child, 'exit');
let stderr = '';
child.stderr.on('data', value => stderr += value);
const queue = [], waiting = [];
createInterface({input:child.stdout}).on('line', line => {
try {const value=JSON.parse(line); if(waiting.length) waiting.shift()(value); else queue.push(value);} catch { /* CLI banner */ }
});
const next = () => new Promise((resolve, reject) => {
const timer=setTimeout(()=>reject(Error(`Fixture timeout: ${stderr}`)),90000);
const receive=value=>{clearTimeout(timer);resolve(value);};
if(queue.length) receive(queue.shift()); else waiting.push(receive);
});
async function fixture(action) {child.stdin.write(action+'\n');return next();}
let browser;
try {
const ready=await next();assert.equal(ready.event,'ready');
browser=await launchBrowser(loadPlaywright().chromium);
const page=await browser.newPage({viewport:{width:1200,height:888}});
await page.addInitScript(()=>localStorage.setItem('loopx-pw-locale','zh-CN'));
await page.goto(ready.origin+'/chat/');
await page.getByRole('navigation',{name:'管家视图'}).getByRole('button',{name:/^(Chat|对话)$/}).click();
const cards=page.locator('.personal-collaboration').filter({hasText:'核对迁移方案 ·'});
await cards.first().waitFor(); assert.equal(await cards.count(),2);
const builder=cards.filter({hasText:'核对迁移方案 · builder'});
const reviewer=cards.filter({hasText:'核对迁移方案 · reviewer'});
assert.match(await builder.innerText(),/尚未记录/);
assert.match(await reviewer.innerText(),/已采纳/);
await page.screenshot({path:resolve(output,'conversation.png')});
const summary=builder.locator('summary');
await summary.focus();await page.keyboard.press('Enter');
await builder.getByText('背景与补充',{exact:true}).waitFor();
await page.setViewportSize({width:390,height:844});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
await page.screenshot({path:resolve(output,'recipient-detail.png')});
await summary.press('Enter');
const before=await fixture('inspect');
await fixture('report-multi');
// The real pending-return observer must discover the reply without a reload.
await page.waitForFunction(()=>[...document.querySelectorAll('.personal-collaboration')].some(
row=>row.textContent.includes('核对迁移方案 · reviewer')&&row.textContent.includes('回复已送达')),null,{timeout:45000});
assert.doesNotMatch(await builder.innerText(),/回复已送达/);
await page.getByText('reviewer 已完成独立核查;builder 的结论仍待返回。',{exact:true}).waitFor();
const after=await fixture('inspect');
assert.equal(after.turn_count,before.turn_count);
assert.equal(after.posts,before.posts,'Observation and result refresh create no additional writes');
console.log(JSON.stringify({ok:true,recipient_count:2,automatic_return:true,remaining_recipient:'builder',
additional_browser_writes:after.posts-before.posts,output}));
} finally {
await browser?.close();
if(child.exitCode===null){child.stdin.write('stop\n');await exited;}
}
1 change: 1 addition & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,7 @@ export type ChatVisibleMessage = {
/** Client-side lineage added when messages from several Sessions are merged. */
session_id?: string;
collaboration?: CollaborationReadback;
collaborations?: CollaborationReadback[];
origin?: string;
attachments?: ChatImageAttachment[];
/** Read-only original-source view; text remains the canonical model input. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,32 @@ assert.equal(queueReadback.length, 1, "Queue admission/history races retain exac
assert.equal(queueReadback[0].sourceMessageId, "user-message");
const crossSession = [...queueReadback, {...queueReadback[0], sourceSessionId: "different"}];
assert.equal(reconcileConversationHistory(crossSession, [storedRoles[0]], createHistory).length, 2, "Message ids are scoped to their original Session");

// One recipient's delivered result must not hide another outstanding answer.
const multiple = [{sourceSessionId: "original", text: "Ask both", collaborations: [
{request_id: "a", returns: [{phase: "conclusion", status: "delivered"}]},
{request_id: "b", returns: []},
]}];
assert.deepEqual(conversationPendingReturnSessions(multiple), ["original"]);
const both = multiple[0].collaborations.map(row => ({...row, returns: [{phase: "conclusion", status: "delivered"}]}));
const identified = [{...multiple[0], sourceMessageId: "multi"}];
const observedBoth = reconcileConversationReturns(identified, "original", [
{message_id: "multi", collaborations: both},
], createReply);
assert.equal(observedBoth[0].text, "Ask both");
assert.deepEqual(conversationPendingReturnSessions(observedBoth), []);
assert.equal(reconcileConversationReturns(observedBoth, "original", [{message_id: "multi"}], createReply), observedBoth);

// A revised brief keeps its return obligation despite a delivered old conclusion.
const revised = [{sourceSessionId: "original", collaborations: [
{request_id: "a", request_revision: 2, returns: [{phase: "conclusion", status: "delivered", request_revision: 1}]},
{request_id: "b", returns: [{phase: "conclusion", status: "delivered"}]},
]}];
assert.deepEqual(conversationPendingReturnSessions(revised), ["original"]);
revised[0].collaborations[0].returns.push({phase: "conclusion", status: "queued", request_revision: 2});
assert.deepEqual(conversationPendingReturnSessions(revised), ["original"]);
revised[0].collaborations[0].returns[1].status = "delivered";
assert.deepEqual(conversationPendingReturnSessions(revised), []);

revised[0].collaborations[0].returns.unshift({phase: "conclusion", status: "superseded", request_revision: 2});
assert.deepEqual(conversationPendingReturnSessions(revised), [], "A superseded result cannot hide the delivered current conclusion");
20 changes: 15 additions & 5 deletions apps/presentation/dashboard/src/data/conversation-returns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type ConversationMessage = {
role?: string;
pending?: boolean;
collaboration?: ChatVisibleMessage["collaboration"];
collaborations?: ChatVisibleMessage["collaborations"];
returnDelivery?: ChatVisibleMessage["return_delivery"];
replyContext?: ChatVisibleMessage["reply_context"];
text?: string;
Expand All @@ -20,13 +21,20 @@ export function conversationReturnSessions(activeSessionId: string | undefined,
return [...sessions].sort();
}

/** Only the current brief's conclusion can settle its outstanding return. */
export function currentCollaborationConclusion(request: NonNullable<ChatVisibleMessage["collaboration"]>) {
const replies = request.returns.filter(reply => reply.phase === "conclusion"
&& (request.request_revision === undefined || (reply.request_revision ?? 1) === request.request_revision));
return replies.find(reply => reply.status === "delivered")
?? replies.find(reply => reply.status !== "superseded") ?? replies[0];
}

/** Metadata can change before a new transcript row is appended. */
export function conversationPendingReturnSessions(messages: ConversationMessage[]): string[] {
const sessions = new Set<string>();
for (const message of messages) {
const waitingForConclusion = message.collaboration && !message.collaboration.returns.some(
(reply) => reply.phase === "conclusion" && reply.status === "delivered",
);
const requests = message.collaborations ?? (message.collaboration ? [message.collaboration] : []);
const waitingForConclusion = requests.some(request => currentCollaborationConclusion(request)?.status !== "delivered");
const delivery = message.returnDelivery;
const waitingForDelivery = delivery && !["delivered", "superseded"].includes(delivery.status);
const waitingForTranscript = message.sourceTurnId && !message.sourceMessageId;
Expand Down Expand Up @@ -61,17 +69,19 @@ export function reconcileConversationReturns<T extends ConversationMessage>(
// its outstanding read obligation until a newer observation arrives.
const returnDelivery = source.return_delivery ?? row.returnDelivery;
const collaboration = source.collaboration ?? row.collaboration;
const collaborations = source.collaborations ?? row.collaborations;
const sourceCreatedAt = source.created_at ?? row.sourceCreatedAt;
const replyContext = source.reply_context ?? row.replyContext;
// Only the exact canonical user body can switch to its source-backed view.
// A background read never replaces a live answer or a different draft.
const text = row.role === "user" && source.reply_context && row.text === source.text
? source.reply_context.source_text : row.text;
if (row.sourceMessageId === source.message_id && JSON.stringify(row.returnDelivery) === JSON.stringify(returnDelivery)
&& JSON.stringify(row.collaboration) === JSON.stringify(collaboration) && row.sourceCreatedAt === sourceCreatedAt
&& JSON.stringify(row.collaboration) === JSON.stringify(collaboration)
&& JSON.stringify(row.collaborations) === JSON.stringify(collaborations) && row.sourceCreatedAt === sourceCreatedAt
&& JSON.stringify(row.replyContext) === JSON.stringify(replyContext) && row.text === text) return row;
changed = true;
return { ...row, sourceMessageId: source.message_id, sourceCreatedAt, returnDelivery, collaboration, replyContext, text };
return { ...row, sourceMessageId: source.message_id, sourceCreatedAt, returnDelivery, collaboration, collaborations, replyContext, text };
});
for (const message of messages) {
if (message.origin !== "manager_followup" || seen.has(message.message_id)) continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MessageActivity } from "./message-activity";
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import {Fragment, useLayoutEffect, useRef, useState} from "react";
import { CollaborationCard } from "./collaboration-card";
import { CollaborationCards } from "./collaboration-card";
import {ConversationReplyContext} from "./conversation-requests";
import { Activity, Bot, Sparkles } from "lucide-react";

Expand Down Expand Up @@ -210,7 +210,7 @@ export function ChannelTimeline({
{item.message.role !== "user" && (item.message.pending || item.message.sourceTurnId || item.message.activity?.length || item.message.steps?.length) ? <MessageActivity message={item.message} onCancelPreparation={onCancelPreparation} onInterruptTurn={onInterruptTurn} onAdjustTurn={onAdjustTurn}/> : null}
{item.message.role === "assistant" && !item.message.pending && item.message.goalDraft
? <GoalDraftCard draftId={`${item.message.sourceSessionId ?? ""}:${item.message.id}`} draft={item.message.goalDraft} onReview={onReviewGoalDraft} onSuggest={onSuggestReply}/> : null}
<CollaborationCard request={item.message.collaboration} />
<CollaborationCards request={item.message.collaboration} requests={item.message.collaborations} />
<ReturnDeliveryStatus delivery={item.message.returnDelivery} />
</div>
</article>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CollaborationReadback } from "../../data/chat-model";
import {currentCollaborationConclusion} from "../../data/conversation-returns";
import {useEffect, useState} from "react";
import type {RequestControlView} from "../../data/request-control";
import {RequestControl} from "./request-control";
Expand Down Expand Up @@ -54,8 +55,7 @@ export function CollaborationCard({ request }: { request?: CollaborationReadback
default: return c.unknown;
}
})();
const conclusion = request.returns.find((reply) => reply.phase === "conclusion" &&
(revision === undefined || (reply.request_revision === undefined ? 1 : reply.request_revision) === revision));
const conclusion = currentCollaborationConclusion({...request, request_revision: revision});
const decisionReason = observed ? observed.assessment?.reason : request.decision_reason;
return <section className="personal-collaboration" aria-label={c.title}>
<header><strong>{brief.purpose}</strong><span>{c.owner}: {request.goal_id ? `${request.goal_id} / ` : ""}{request.agent_id}</span></header>
Expand All @@ -81,3 +81,9 @@ export function CollaborationCard({ request }: { request?: CollaborationReadback
</details>
</section>;
}

/** Each recipient retains its own request controls, decision and return. */
export function CollaborationCards({request, requests}: {request?: CollaborationReadback; requests?: CollaborationReadback[]}) {
return <>{(requests ?? (request ? [request] : [])).map(row =>
<CollaborationCard key={row.request_id} request={row} />)}</>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ export type WorkspaceMessage = {
activity?: string[];
steps?: TurnStep[];
collaboration?: CollaborationReadback;
collaborations?: CollaborationReadback[];
agentLabel?: string;
attachments?: WorkspaceImageAttachment[];
id: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { composerKeyAction } from "./composer-keyboard";
import type { ConversationHistoryStatus } from "../../data/use-conversation-history";
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import { CollaborationCard } from "./collaboration-card";
import { CollaborationCards } from "./collaboration-card";
import {ConversationRequests, ConversationReplyContext, OriginalProjectRequests} from "./conversation-requests";
import {
compileActionReviewPlan,
Expand Down Expand Up @@ -484,7 +484,7 @@ function ManagerConversationTray({
<ConversationReplyContext context={message.replyContext} zh={locale === "zh-CN"}/>
{message.role === "assistant" ? <MessageActivity message={message} onInterruptTurn={onInterruptTurn} onAdjustTurn={onAdjustTurn} onCancelPreparation={onCancelPreparation} /> : null}
{message.role === "assistant" && !message.pending && message.goalDraft ? <GoalDraftCard draftId={`${message.sourceSessionId ?? ""}:${message.id}`} draft={message.goalDraft} onReview={onReviewGoalDraft} onSuggest={onSuggestReply}/> : null}
<CollaborationCard request={message.collaboration} />
<CollaborationCards request={message.collaboration} requests={message.collaborations} />
<ReturnDeliveryStatus delivery={message.returnDelivery} />
</div>
</article>
Expand Down
9 changes: 5 additions & 4 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ type PersonalManagerMessage = {
endedAt?: number;
returnDelivery?: ChatVisibleMessage["return_delivery"];
collaboration?: ChatVisibleMessage["collaboration"];
collaborations?: ChatVisibleMessage["collaborations"];
reconnect?: boolean;
role: "assistant" | "user";
sourceLabel?: string;
Expand Down Expand Up @@ -1629,7 +1630,7 @@ function PersonalGoalHome({
sourceSessionId: sessionId, sourceCreatedAt: row.created_at,
role: "assistant" as const,
agentLabel: "协作回执", sourceLabel: "协作回执", text: visibleAgentMessage(row.text), lines: [],
returnDelivery: row.return_delivery, collaboration: row.collaboration,
returnDelivery: row.return_delivery, collaboration: row.collaboration, collaborations: row.collaborations,
}));
if (updated !== previous) next = { ...next, [targetContextId]: updated };
}
Expand Down Expand Up @@ -1752,7 +1753,7 @@ function PersonalGoalHome({
attachments: workspaceImageAttachments(message.attachments),
id: managerMessageId.current++, lines: [],
role: message.role === "user" ? "user" : "assistant",
returnDelivery: message.return_delivery, collaboration: message.collaboration,
returnDelivery: message.return_delivery, collaboration: message.collaboration, collaborations: message.collaborations,
replyContext: message.reply_context,
sourceLabel: message.role === "user" ? undefined : message.role === "error" ? "本地会话记录"
: contextId === "manager" ? `恢复的${t("header.manager")}会话` : `恢复的 ${sessionLabels.get(message.session_id ?? "") ?? selectedAgent.label} 会话`,
Expand Down Expand Up @@ -2504,7 +2505,7 @@ function PersonalGoalHome({
if (answer) updateConversationMessage(targetContextId, completedMessageId, {
sourceMessageId: answer.message_id, sourceSessionId: sessionId,
sourceCreatedAt: answer.created_at,
collaboration: answer.collaboration, returnDelivery: answer.return_delivery,
collaboration: answer.collaboration, collaborations: answer.collaborations, returnDelivery: answer.return_delivery,
});
if (targetContextId === contextId) void conversationHistory.refresh().catch(() => {});
}).catch(() => { /* The original conversation remains readable. */ });
Expand Down Expand Up @@ -2909,7 +2910,7 @@ function PersonalGoalHome({
updatedAt: message.updatedAt,
endedAt: message.endedAt,
returnDelivery: message.returnDelivery,
collaboration: message.collaboration,
collaboration: message.collaboration, collaborations: message.collaborations,
replyContext: message.replyContext,
goalDraft: message.goalDraft,
role: message.role,
Expand Down
Loading
Loading