Skip to content

Commit 3156771

Browse files
authored
Merge pull request #5368 from loopx-project/codex/app-owner-controls-1001
2 parents 1d5795c + 2faa6c4 commit 3156771

9 files changed

Lines changed: 195 additions & 53 deletions

File tree

‎apps/presentation/dashboard/src/data/chat.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,11 @@ export function chatSessionQueuesFollowUps(session: Pick<ChatSessionSummary, "se
703703
return session.session_mode === "attached_host";
704704
}
705705

706+
/** Native steering is offered only by the managed Codex adapter; attached follow-ups keep their queue contract. */
707+
export function chatSessionSupportsSteering(session: Pick<ChatSessionSummary, "session_mode" | "adapter_kind">) {
708+
return session.session_mode !== "attached_host" && session.adapter_kind === "codex_app_server";
709+
}
710+
706711
export type ManagerRuntimeSessionReadback = {
707712
schema_version: "manager_runtime_session_readback_v0";
708713
runtime_profile: "restricted" | "trusted_owner";

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx‎

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,7 @@ function readImageAttachment(file: File, t: WorkspaceTranslate): Promise<Workspa
750750

751751
export function PersonalWorkspacePage({
752752
conversationQueuesFollowUps = false,
753+
conversationSupportsSteering = false,
753754
conversationSessionId,
754755
conversationHistoryState,
755756
agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }],
@@ -767,6 +768,8 @@ export function PersonalWorkspacePage({
767768
}: {
768769
/** The bound Session's mode queues a message sent while its Turn runs. */
769770
conversationQueuesFollowUps?: boolean;
771+
/** The bound managed executor offers native exact-turn steering. */
772+
conversationSupportsSteering?: boolean;
770773
conversationSessionId?: string;
771774
conversationHistoryState?: ConversationHistoryStatus;
772775
agents?: WorkspaceAgentOption[];
@@ -806,6 +809,8 @@ export function PersonalWorkspacePage({
806809
}
807810
});
808811
const [sending, setSending] = useState(false);
812+
const [steering, setSteering] = useState(false);
813+
const steeringRequests = useRef(new Map<string, { sessionId: string; turnId: string; text: string; id: string }>());
809814
const [actionDraft, setActionDraft] = useState<WorkspaceActionDraft | null>(null);
810815
const [loopxMode, setLoopxMode] = useState<LoopXModeSnapshot | null>(null);
811816
const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue");
@@ -848,8 +853,9 @@ export function PersonalWorkspacePage({
848853
input.style.height = "auto";
849854
input.style.height = `${Math.min(input.scrollHeight, 120)}px`;
850855
}, [composer, selectedGoalId, managerChatOpen]);
851-
function setComposerDraft(key: string, value: string) {
856+
function setComposerDraft(key: string, value: string, expectedValue?: string) {
852857
setDrafts((current) => {
858+
if (expectedValue !== undefined && current[key] !== expectedValue) return current;
853859
const next = { ...current };
854860
if (value) {
855861
next[key] = value;
@@ -1027,18 +1033,17 @@ export function PersonalWorkspacePage({
10271033
setGoalConversationReceiptVisible(true);
10281034
}
10291035
}, [goalMessages, selectedGoal, selectedGoalTab]);
1030-
// A managed runtime Session admits one Turn at a time. While the current
1031-
// Session shows a Turn in flight, a new message would only be rejected, so
1032-
// the composer waits and points to the reply's own adjust/interrupt
1033-
// controls. Two deliveries stay open because the service queues them behind
1034-
// the running Turn: LoopX mode through its own queue, and any message to an
1035-
// attached host Session.
1036+
// One composer for both conversations. Running managed Codex work receives
1037+
// exact-turn instructions; attached hosts and LoopX mode keep their queues.
10361038
const loopxDeliveryOpen = Boolean(conversationSessionId && loopxMode?.session_id === conversationSessionId
10371039
&& loopxMode?.enabled && loopxMode.active_turn_id);
1038-
const conversationTurnRunning = !loopxDeliveryOpen && !conversationQueuesFollowUps && Boolean(conversationSessionId)
1039-
&& managerMessages.some((message) => message.pending && Boolean(message.sourceTurnId)
1040-
&& message.sourceSessionId === conversationSessionId);
1041-
const composerBlocked = sending || conversationTurnRunning;
1040+
const runningMessage = managerMessages.find((message) => message.pending && Boolean(message.sourceTurnId)
1041+
&& message.sourceSessionId === conversationSessionId);
1042+
const conversationTurnRunning = !loopxDeliveryOpen && !conversationQueuesFollowUps && Boolean(runningMessage);
1043+
const steeringTurnId = conversationTurnRunning && conversationSupportsSteering && !readOnly
1044+
&& callbacks.onSteerConversationTurn ? runningMessage?.sourceTurnId : undefined;
1045+
const composerBlocked = steering || (!steeringTurnId && (sending || conversationTurnRunning));
1046+
const quickPromptBlocked = steering || sending || conversationTurnRunning;
10421047
const managerChatItems = useMemo(
10431048
() => items.filter((item) => item.kind === "message"
10441049
|| (item.kind === "proposal" && (sessionProposalIds.includes(item.proposal.previewId)
@@ -1626,7 +1631,36 @@ export function PersonalWorkspacePage({
16261631
async function sendMessage(messageOverride?: string) {
16271632
const pendingImages = messageOverride ? [] : imageAttachments;
16281633
const message = (messageOverride ?? composer).trim() || (pendingImages.length ? t("composer.imageAnalysisPrompt") : "");
1629-
if (!message || sending || conversationHistoryState?.sendBlocked) return;
1634+
if (!message || composerBlocked || conversationHistoryState?.sendBlocked) return;
1635+
const previousSteering = steeringRequests.current.get(composerDraftKey);
1636+
const retry = previousSteering && previousSteering.sessionId === conversationSessionId && previousSteering.text === message
1637+
? previousSteering : undefined;
1638+
if ((retry || steeringTurnId) && conversationSessionId && callbacks.onSteerConversationTurn) {
1639+
if (pendingImages.length) {
1640+
setImageAttachmentError(locale === "zh-CN" ? "本轮追加指令暂不支持图片,图片和草稿已保留。" : "This turn accepts text instructions only. Images and draft retained.");
1641+
return;
1642+
}
1643+
const request = retry ?? { sessionId: conversationSessionId, turnId: steeringTurnId!, text: message, id: crypto.randomUUID() };
1644+
steeringRequests.current.set(composerDraftKey, request);
1645+
setSteering(true);
1646+
setActionFeedback(null);
1647+
setImageAttachmentError(null);
1648+
try {
1649+
await callbacks.onSteerConversationTurn(selectedGoalId ?? "manager", request.turnId, message, request.id);
1650+
steeringRequests.current.delete(composerDraftKey);
1651+
if (!messageOverride) setComposerDraft(composerDraftKey, "", composer);
1652+
setActionFeedback(locale === "zh-CN" ? "执行器已接收本轮追加指令。" : "The executor accepted instructions for this turn.");
1653+
} catch (error) {
1654+
// Unknown delivery retries the original Turn even after it completes.
1655+
// A confirmed non-delivery may use a new ingress after recovery.
1656+
if (error instanceof ChatApiError && error.payload.delivery_state === "not_delivered") {
1657+
steeringRequests.current.delete(composerDraftKey);
1658+
}
1659+
setActionFeedback(error instanceof Error ? error.message : t("feedback.sendGenericError"));
1660+
} finally { setSteering(false); }
1661+
return;
1662+
}
1663+
if (sending) return;
16301664
followConversationRef.current = true;
16311665
setShowLatestMessage(false);
16321666
if (loopxMode?.session_id === conversationSessionId && loopxMode?.enabled && loopxMode.active_turn_id && conversationSessionId) {
@@ -1969,16 +2003,16 @@ export function PersonalWorkspacePage({
19692003
<summary>{locale === "zh-CN" ? "快捷提问" : "Suggestions"}</summary>
19702004
{selectedGoal ? (
19712005
<div className="personal-quick-prompts">
1972-
<button aria-label={t("composer.nextAction")} disabled={composerBlocked} onClick={() => void sendMessage(t("composer.nextActionPrompt"))} title={t("composer.sendMessageHint")} type="button"><MessageCircleQuestion size={13} /><span>{t("composer.nextAction")}</span></button>
1973-
<button aria-label={t("composer.agentProgress")} disabled={composerBlocked} onClick={() => void sendMessage(t("composer.agentProgressPrompt"))} title={t("composer.sendMessageHint")} type="button"><Send size={13} /><span>{t("composer.agentProgress")}</span></button>
2006+
<button aria-label={t("composer.nextAction")} disabled={quickPromptBlocked} onClick={() => void sendMessage(t("composer.nextActionPrompt"))} title={t("composer.sendMessageHint")} type="button"><MessageCircleQuestion size={13} /><span>{t("composer.nextAction")}</span></button>
2007+
<button aria-label={t("composer.agentProgress")} disabled={quickPromptBlocked} onClick={() => void sendMessage(t("composer.agentProgressPrompt"))} title={t("composer.sendMessageHint")} type="button"><Send size={13} /><span>{t("composer.agentProgress")}</span></button>
19742008
<button aria-label={t("composer.monitor")} disabled={sending} onClick={() => prepareScheduleDraft("monitor", selectedGoalId)} title={t("composer.sendMessageHint")} type="button"><CalendarClock size={13} /><span>{t("composer.monitor")}</span></button>
1975-
<button aria-label={t("composer.blockers")} disabled={composerBlocked || !stewardPromptText("gate")} onClick={() => void sendMessage(stewardPromptText("gate"))} title={t("composer.sendMessageHint")} type="button"><AlertCircle size={13} /><span>{t("composer.blockers")}</span></button>
1976-
<button aria-label={t("composer.evidence")} disabled={composerBlocked || !stewardPromptText("evidence")} onClick={() => void sendMessage(stewardPromptText("evidence"))} title={t("composer.sendMessageHint")} type="button"><FileText size={13} /><span>{t("composer.evidence")}</span></button>
2009+
<button aria-label={t("composer.blockers")} disabled={quickPromptBlocked || !stewardPromptText("gate")} onClick={() => void sendMessage(stewardPromptText("gate"))} title={t("composer.sendMessageHint")} type="button"><AlertCircle size={13} /><span>{t("composer.blockers")}</span></button>
2010+
<button aria-label={t("composer.evidence")} disabled={quickPromptBlocked || !stewardPromptText("evidence")} onClick={() => void sendMessage(stewardPromptText("evidence"))} title={t("composer.sendMessageHint")} type="button"><FileText size={13} /><span>{t("composer.evidence")}</span></button>
19772011
</div>
19782012
) : (
19792013
<div className="personal-quick-prompts">
1980-
<button aria-label={t("composer.globalTasks")} disabled={composerBlocked} onClick={() => void sendMessage(t("composer.globalTasksPrompt"))} title={t("composer.sendMessageHint")} type="button"><MessageCircleQuestion size={13} /><span>{t("composer.globalTasks")}</span></button>
1981-
<button aria-label={t("composer.globalProgress")} disabled={composerBlocked} onClick={() => void sendMessage(t("composer.globalProgressPrompt"))} title={t("composer.sendMessageHint")} type="button"><Send size={13} /><span>{t("composer.globalProgress")}</span></button>
2014+
<button aria-label={t("composer.globalTasks")} disabled={quickPromptBlocked} onClick={() => void sendMessage(t("composer.globalTasksPrompt"))} title={t("composer.sendMessageHint")} type="button"><MessageCircleQuestion size={13} /><span>{t("composer.globalTasks")}</span></button>
2015+
<button aria-label={t("composer.globalProgress")} disabled={quickPromptBlocked} onClick={() => void sendMessage(t("composer.globalProgressPrompt"))} title={t("composer.sendMessageHint")} type="button"><Send size={13} /><span>{t("composer.globalProgress")}</span></button>
19822016
<button aria-label={t("composer.createGoal")} onClick={requestGoalCreate} title={t("composer.createGoalHint")} type="button"><Plus size={13} /><span>{t("composer.createGoal")}</span></button>
19832017
</div>
19842018
)}
@@ -1991,7 +2025,9 @@ export function PersonalWorkspacePage({
19912025
</figure>
19922026
))}</div> : null}
19932027
{imageAttachmentError ? <p className="personal-composer-error" role="alert">{imageAttachmentError}</p> : null}
1994-
{conversationTurnRunning ? <p className="personal-composer-status" role="status">{t("composer.turnRunning")}</p> : null}
2028+
{conversationTurnRunning ? <p className="personal-composer-status" role="status">{steeringTurnId
2029+
? (locale === "zh-CN" ? "本轮进行中 · 发消息可调整当前工作" : "Turn in progress · send instructions to adjust this work")
2030+
: t("composer.turnRunning")}</p> : null}
19952031
<div
19962032
className="personal-channel-composer"
19972033
onDragOver={(event) => {
@@ -2034,7 +2070,9 @@ export function PersonalWorkspacePage({
20342070
/>
20352071
<button aria-label={t("composer.send")} disabled={(!composer.trim() && imageAttachments.length === 0) || composerBlocked || conversationHistoryState?.sendBlocked} onClick={() => void sendMessage()} title={t("composer.sendMessageHint")} type="button"><Send size={18} /></button>
20362072
</div>
2037-
{conversationOpen ? <div className="personal-composer-hint">{sending
2073+
{conversationOpen ? <div className="personal-composer-hint">{steering
2074+
? (locale === "zh-CN" ? "正在发送本轮追加指令…" : "Sending instructions for this turn…")
2075+
: sending && !steeringTurnId
20382076
? (locale === "zh-CN" ? "正在回复 · 修改当前任务请使用“调整本轮”" : "Reply in progress · use Adjust turn to change the current task")
20392077
: (locale === "zh-CN" ? "Enter 发送 · Shift+Enter 换行" : "Enter to send · Shift+Enter for a new line")}</div> : null}
20402078
</>}

‎apps/presentation/dashboard/src/views/dashboard-page.tsx‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
resumeChatTurnStreaming,
5757
sendChatTurnStreaming,
5858
chatSessionQueuesFollowUps,
59+
chatSessionSupportsSteering,
5960
selectAvailableChatAgent,
6061
sessionInvalidatedByPayload,
6162
todoNoWriteReceiptFromPayload,
@@ -1410,6 +1411,7 @@ function PersonalGoalHome({
14101411
// Bound Sessions whose mode queues a message sent while a Turn runs, read
14111412
// from the Session owner each time this page binds a Session.
14121413
const [followUpQueueSessionIds, setFollowUpQueueSessionIds] = useState<ReadonlySet<string>>(() => new Set());
1414+
const [steeringSessionIds, setSteeringSessionIds] = useState<ReadonlySet<string>>(() => new Set());
14131415
const [executionSessions, setExecutionSessions] = useState<ChatSessionSummary[]>([]);
14141416
// Bumped when the service reports a running Turn this page did not know
14151417
// about, so the Turn recovery effect re-reads the Session and adopts it.
@@ -1559,6 +1561,14 @@ function PersonalGoalHome({
15591561

15601562
function recordSessionAdmission(session: ChatSessionSummary) {
15611563
const queues = chatSessionQueuesFollowUps(session);
1564+
const supportsSteering = chatSessionSupportsSteering(session);
1565+
setSteeringSessionIds((current) => {
1566+
if (current.has(session.session_id) === supportsSteering) return current;
1567+
const next = new Set(current);
1568+
if (supportsSteering) next.add(session.session_id);
1569+
else next.delete(session.session_id);
1570+
return next;
1571+
});
15621572
setFollowUpQueueSessionIds((current) => {
15631573
if (current.has(session.session_id) === queues) return current;
15641574
const next = new Set(current);
@@ -2939,9 +2949,9 @@ function PersonalGoalHome({
29392949
},
29402950
onSteerConversationTurn: async (targetContextId, turnId, message, ingressId) => {
29412951
const binding = runtimeBindings[targetContextId];
2942-
if (!binding?.sessionId || binding.turnId !== turnId || activeTurnIds.current.get(targetContextId) !== turnId) {
2943-
throw new Error("本轮已结束或已被新的回合取代,追加指令未发送,草稿已保留。");
2944-
}
2952+
if (!binding?.sessionId) throw new Error("当前会话不可用,追加指令未发送,草稿已保留。");
2953+
// The service owns exact-turn admission and durable retry. A delivered
2954+
// ingress may be read back after completion; never retarget it locally.
29452955
await steerChatTurn(binding.sessionId, turnId, message, ingressId);
29462956
const id = managerMessageId.current++;
29472957
setMessagesByContext(current => {
@@ -3081,6 +3091,7 @@ function PersonalGoalHome({
30813091
managerRuntime={managerRuntime}
30823092
conversationSessionId={runtimeBindings[contextId]?.sessionId}
30833093
conversationQueuesFollowUps={followUpQueueSessionIds.has(runtimeBindings[contextId]?.sessionId ?? "")}
3094+
conversationSupportsSteering={steeringSessionIds.has(runtimeBindings[contextId]?.sessionId ?? "")}
30843095
conversationHistoryState={conversationHistory}
30853096
model={workspaceModel}
30863097
readOnly={readOnly}

‎docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@ receipt → observed work or actionable failure → readable answer in the same
126126
- Before dispatch, cancel only session preparation and state that the request
127127
was not submitted. After acceptance, existing exact-turn steering/interrupt
128128
controls own effects; stopping observation is not stopping the worker.
129+
- During a managed Codex Turn, the ordinary composer sends text instructions to
130+
that exact Turn, without a second adjustment form or another Turn submission.
131+
Attached-host messages keep their next-Turn queue semantics; unsupported
132+
managed adapters keep the draft without advertising native steering. The
133+
idle composer and initial presentation are unchanged. This changes the former
134+
managed-running Send lockout; LoopX mode retains its explicit delivery choice.
135+
A lost or mismatched receipt preserves draft and ingress identity, including
136+
retry after completion. Only a confirmed non-delivery permits a new ingress.
137+
Acceptance means the executor received the instructions, not that it adopted
138+
them or that delegated/team work stopped. Live adoption stays a release gate.
129139
- The compact receipt and full conversation offer the same controls. Failure
130140
ends the live indicator, preserves the request/partial answer and names the
131141
next supported action. A completed delegation still shows receiver adoption

0 commit comments

Comments
 (0)