Skip to content

Commit b77a336

Browse files
committed
feat(chat): wire structured group member delivery
Submit canonical Member pill snapshots through the multi-target RPC, preserve immutable retry envelopes after unknown transport outcomes, surface typed limits, and expose isolated fault/evidence support for packaged-app acceptance.\n\nVerification:\n- pnpm exec vitest run src/engines/ChatPanel/hooks/groupChatRouting.test.ts src/engines/ChatPanel/hooks/useAgentOrgGroupChatController.test.ts src/engines/ChatPanel/hooks/useInputArea/__tests__/composerMemberPillSnapshot.test.ts src/engines/ChatPanel/hooks/useInputArea/__tests__/submissionErrors.test.ts — 17 passed\n- pnpm typecheck — passed\n- targeted ESLint and Prettier — passed Pre-commit hook ran. Total eslint: 5, total circular: 0
1 parent 654af39 commit b77a336

17 files changed

Lines changed: 918 additions & 392 deletions

src-tauri/src/api/agent/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,14 @@ pub fn create_routes() -> Router {
767767
"/test/agent-org/simulate-app-restart",
768768
post(test::agent_org::test_agent_org_simulate_app_restart),
769769
)
770+
.route(
771+
"/test/agent-org/user-directed/fault",
772+
post(test::agent_org_user_directed::test_agent_org_user_directed_fault),
773+
)
774+
.route(
775+
"/test/agent-org/user-directed/evidence",
776+
post(test::agent_org_user_directed::test_agent_org_user_directed_evidence),
777+
)
770778
.route(
771779
"/test/agent-org/check-member-spawn-gate",
772780
post(test::agent_org::test_agent_org_check_member_spawn_gate),
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
//! Debug-only fault setup and read-only evidence for UserDirectedWork.
2+
//!
3+
//! These endpoints cannot submit, retry, pause, resume, archive, or delete a
4+
//! Team. They only arm one BuildFast fault or inspect bounded durable rows so
5+
//! Computer Use can remain the sole operator of every visible user action.
6+
7+
use axum::Json;
8+
use rusqlite::params;
9+
10+
pub async fn test_agent_org_user_directed_fault(
11+
Json(body): Json<serde_json::Value>,
12+
) -> Json<serde_json::Value> {
13+
let Some(mode) = body.get("mode").and_then(serde_json::Value::as_str) else {
14+
return Json(serde_json::json!({
15+
"ok": false,
16+
"error": "mode is required"
17+
}));
18+
};
19+
match agent_core::state::commands::session::org_tasks::arm_next_group_delivery_fault(mode) {
20+
Ok(()) => Json(serde_json::json!({ "ok": true, "mode": mode })),
21+
Err(error) => Json(serde_json::json!({ "ok": false, "error": error })),
22+
}
23+
}
24+
25+
pub async fn test_agent_org_user_directed_evidence(
26+
Json(body): Json<serde_json::Value>,
27+
) -> Json<serde_json::Value> {
28+
let Some(org_run_id) = body
29+
.get("org_run_id")
30+
.and_then(serde_json::Value::as_str)
31+
.map(str::trim)
32+
.filter(|value| !value.is_empty())
33+
.map(ToOwned::to_owned)
34+
else {
35+
return Json(serde_json::json!({
36+
"ok": false,
37+
"error": "org_run_id is required"
38+
}));
39+
};
40+
41+
let result = tokio::task::spawn_blocking(move || load_evidence(&org_run_id)).await;
42+
match result {
43+
Err(error) => Json(serde_json::json!({
44+
"ok": false,
45+
"error": format!("evidence worker failed: {error}")
46+
})),
47+
Ok(Err(error)) => Json(serde_json::json!({ "ok": false, "error": error })),
48+
Ok(Ok(value)) => Json(value),
49+
}
50+
}
51+
52+
fn load_evidence(org_run_id: &str) -> Result<serde_json::Value, String> {
53+
let conn = database::db::get_connection().map_err(|error| error.to_string())?;
54+
let mut delivery_statement = conn
55+
.prepare(
56+
"SELECT delivery_id,session_id,turn_intent_id,root_authority_turn_id,
57+
parent_delivery_id,parent_inbox_id,source_kind,source_inbox_id,
58+
dispatch_member_id,member_dispatch_sequence,depth,delivery_ordinal,status
59+
FROM agent_org_runtime_user_directed_deliveries
60+
WHERE org_run_id=?1
61+
ORDER BY delivery_id
62+
LIMIT 200",
63+
)
64+
.map_err(|error| error.to_string())?;
65+
let deliveries = delivery_statement
66+
.query_map([org_run_id], |row| {
67+
Ok(serde_json::json!({
68+
"delivery_id": row.get::<_, i64>(0)?,
69+
"session_id": row.get::<_, String>(1)?,
70+
"turn_intent_id": row.get::<_, String>(2)?,
71+
"root_authority_turn_id": row.get::<_, String>(3)?,
72+
"parent_delivery_id": row.get::<_, Option<i64>>(4)?,
73+
"parent_inbox_id": row.get::<_, Option<i64>>(5)?,
74+
"source_kind": row.get::<_, String>(6)?,
75+
"source_inbox_id": row.get::<_, Option<i64>>(7)?,
76+
"dispatch_member_id": row.get::<_, String>(8)?,
77+
"member_dispatch_sequence": row.get::<_, i64>(9)?,
78+
"depth": row.get::<_, i64>(10)?,
79+
"delivery_ordinal": row.get::<_, i64>(11)?,
80+
"status": row.get::<_, String>(12)?,
81+
}))
82+
})
83+
.map_err(|error| error.to_string())?
84+
.collect::<rusqlite::Result<Vec<_>>>()
85+
.map_err(|error| error.to_string())?;
86+
87+
let mut binding_statement = conn
88+
.prepare(
89+
"SELECT binding_id,session_id,turn_intent_id,root_authority_turn_id,
90+
parent_delivery_id,parent_inbox_id,source_inbox_id,depth,
91+
delivery_ordinal,status
92+
FROM agent_org_runtime_user_directed_coordinator_bindings
93+
WHERE org_run_id=?1
94+
ORDER BY binding_id
95+
LIMIT 200",
96+
)
97+
.map_err(|error| error.to_string())?;
98+
let coordinator_bindings = binding_statement
99+
.query_map([org_run_id], |row| {
100+
Ok(serde_json::json!({
101+
"binding_id": row.get::<_, i64>(0)?,
102+
"session_id": row.get::<_, String>(1)?,
103+
"turn_intent_id": row.get::<_, String>(2)?,
104+
"root_authority_turn_id": row.get::<_, String>(3)?,
105+
"parent_delivery_id": row.get::<_, i64>(4)?,
106+
"parent_inbox_id": row.get::<_, Option<i64>>(5)?,
107+
"source_inbox_id": row.get::<_, i64>(6)?,
108+
"depth": row.get::<_, i64>(7)?,
109+
"delivery_ordinal": row.get::<_, i64>(8)?,
110+
"status": row.get::<_, String>(9)?,
111+
}))
112+
})
113+
.map_err(|error| error.to_string())?
114+
.collect::<rusqlite::Result<Vec<_>>>()
115+
.map_err(|error| error.to_string())?;
116+
117+
let mut inbox_statement = conn
118+
.prepare(
119+
"SELECT id,recipient_member_id,sender_member_id,read_at
120+
FROM agent_org_runtime_inbox
121+
WHERE org_run_id=?1 AND delivery_class='user_directed'
122+
ORDER BY id
123+
LIMIT 200",
124+
)
125+
.map_err(|error| error.to_string())?;
126+
let inbox = inbox_statement
127+
.query_map([org_run_id], |row| {
128+
Ok(serde_json::json!({
129+
"id": row.get::<_, i64>(0)?,
130+
"recipient_member_id": row.get::<_, Option<String>>(1)?,
131+
"sender_member_id": row.get::<_, Option<String>>(2)?,
132+
"read_at": row.get::<_, Option<String>>(3)?,
133+
}))
134+
})
135+
.map_err(|error| error.to_string())?
136+
.collect::<rusqlite::Result<Vec<_>>>()
137+
.map_err(|error| error.to_string())?;
138+
139+
let (root_count, context_count, intent_count, tool_receipt_count): (i64, i64, i64, i64) = conn
140+
.query_row(
141+
"SELECT
142+
(SELECT COUNT(*) FROM agent_org_runtime_user_directed_roots
143+
WHERE org_run_id=?1),
144+
(SELECT COUNT(*) FROM agent_org_runtime_turn_contexts
145+
WHERE org_run_id=?1 AND (
146+
turn_kind='user_directed_work'
147+
OR (turn_kind='coordinator' AND source_kind='member_inbox')
148+
)),
149+
(SELECT COUNT(*) FROM session_turn_intents WHERE org_run_id=?1),
150+
(SELECT COUNT(*) FROM agent_org_runtime_tool_call_receipts
151+
WHERE org_run_id=?1)",
152+
params![org_run_id],
153+
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
154+
)
155+
.map_err(|error| error.to_string())?;
156+
157+
Ok(serde_json::json!({
158+
"ok": true,
159+
"org_run_id": org_run_id,
160+
"bounded": true,
161+
"row_limit": 200,
162+
"counts": {
163+
"roots": root_count,
164+
"user_directed_contexts": context_count,
165+
"all_run_intents": intent_count,
166+
"tool_receipts": tool_receipt_count,
167+
"deliveries": deliveries.len(),
168+
"coordinator_bindings": coordinator_bindings.len(),
169+
"user_directed_inbox": inbox.len(),
170+
},
171+
"deliveries": deliveries,
172+
"coordinator_bindings": coordinator_bindings,
173+
"inbox": inbox,
174+
}))
175+
}

src-tauri/src/api/agent/test/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
99
pub mod agent_org;
1010
pub mod agent_org_formal_convergence;
11+
pub mod agent_org_user_directed;
1112
pub mod cli;
1213
pub mod core;
1314
pub mod desktop;

src/api/tauri/agent/orgTasks.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,12 +378,25 @@ export interface AgentOrgPlanRevision {
378378
/** @deprecated Use AgentOrgPlanRevision. */
379379
export type AgentOrgPlanApproval = AgentOrgPlanRevision;
380380

381-
export interface AgentOrgGroupChatMessageResponse {
381+
export interface AgentOrgGroupDeliveryInput {
382+
targetMemberId: string;
383+
turnIntentId: string;
384+
}
385+
386+
export interface AgentOrgGroupDeliveryResponse {
382387
targetMemberId: string;
383388
targetMemberName: string;
389+
turnIntentId: string;
390+
sourceInboxId: number;
391+
memberDispatchSequence: number;
392+
outcome: "accepted" | "existing";
384393
inboxRow: AgentOrgInboxRuntimeRow;
385394
}
386395

396+
export interface AgentOrgGroupChatMessageResponse {
397+
deliveries: AgentOrgGroupDeliveryResponse[];
398+
}
399+
387400
type AgentOrgStateChangeSubscriber = (sessionId: string) => void;
388401

389402
const agentOrgStateChangeSubscribers = new Set<AgentOrgStateChangeSubscriber>();
@@ -739,17 +752,19 @@ export async function returnAgentOrgSessionToWork(
739752

740753
export async function sendAgentOrgGroupChatMessage(
741754
sessionId: string,
742-
targetMemberId: string | null,
755+
deliveries: AgentOrgGroupDeliveryInput[],
743756
content: string,
744-
displayText?: string
757+
displayText?: string,
758+
images?: string[]
745759
): Promise<AgentOrgGroupChatMessageResponse> {
746760
const response = await invokeTauri<AgentOrgGroupChatMessageResponse>(
747761
"agent_org_send_group_chat_message",
748762
{
749763
sessionId,
750-
targetMemberId,
764+
deliveries,
751765
content,
752766
displayText: displayText ?? null,
767+
images: images?.length ? images : null,
753768
}
754769
);
755770
publishAgentOrgStateChange(sessionId);

src/engines/ChatPanel/ChatFloatingComposer.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ interface AgentOrgInterventionView {
6464

6565
interface GroupChatPendingMessageView {
6666
targetMemberName: string;
67+
retryError: string | null;
68+
retrying: boolean;
69+
onRetry: () => Promise<void>;
6770
}
6871

6972
interface CanvasPreviewPillView {
@@ -320,15 +323,45 @@ const ChatFloatingComposer: React.FC<ChatFloatingComposerProps> = memo(
320323
<div
321324
data-testid="agent-org-group-chat-pending"
322325
data-target-name={groupChatPendingMessage.targetMemberName}
326+
data-delivery-state={
327+
groupChatPendingMessage.retryError ? "unknown" : "pending"
328+
}
323329
className="bg-background-2 mx-auto flex items-center gap-2 rounded-full border border-solid border-border-2 px-3 py-1 text-[12px] text-text-2 shadow-sm"
324330
>
325-
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary-6" />
326-
<span>
327-
{t("groupChat.userMessagePending", {
328-
member: groupChatPendingMessage.targetMemberName,
329-
defaultValue: "{{member}} is picking up your message",
330-
})}
331-
</span>
331+
{groupChatPendingMessage.retryError ? (
332+
<>
333+
<span className="h-1.5 w-1.5 rounded-full bg-warning-6" />
334+
<span title={groupChatPendingMessage.retryError}>
335+
{t("groupChat.userMessageOutcomeUnknown", {
336+
defaultValue:
337+
"Delivery outcome unknown. Retry with the same IDs.",
338+
})}
339+
</span>
340+
<Button
341+
data-testid="agent-org-group-chat-retry"
342+
variant="secondary"
343+
appearance="outline"
344+
size="mini"
345+
shape="round"
346+
htmlType="button"
347+
loading={groupChatPendingMessage.retrying}
348+
disabled={groupChatPendingMessage.retrying}
349+
onClick={() => void groupChatPendingMessage.onRetry()}
350+
>
351+
{t("common:actions.retry", { defaultValue: "Retry" })}
352+
</Button>
353+
</>
354+
) : (
355+
<>
356+
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary-6" />
357+
<span>
358+
{t("groupChat.userMessagePending", {
359+
member: groupChatPendingMessage.targetMemberName,
360+
defaultValue: "{{member}} is picking up your message",
361+
})}
362+
</span>
363+
</>
364+
)}
332365
</div>
333366
)}
334367

src/engines/ChatPanel/ChatView.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -486,12 +486,11 @@ const ChatView: React.FC<ChatViewProps> = memo(
486486
queueEditProps,
487487
disableStopWhenEmpty: groupChatViewActive,
488488
submitDisabled:
489-
(groupChatViewActive && agentOrgRunView?.runStatus === "paused") ||
490-
(!groupChatViewActive &&
491-
currentAgentOrgMember !== null &&
492-
!currentAgentOrgMember.isCoordinator &&
493-
(agentOrgRunView?.runStatus === "starting" ||
494-
agentOrgRunView?.runStatus === "failed")),
489+
!groupChatViewActive &&
490+
currentAgentOrgMember !== null &&
491+
!currentAgentOrgMember.isCoordinator &&
492+
(agentOrgRunView?.runStatus === "starting" ||
493+
agentOrgRunView?.runStatus === "failed"),
495494
}),
496495
[
497496
sessionId,

src/engines/ChatPanel/ChatViewComposerSection.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ interface AgentOrgInterventionView {
3232

3333
interface GroupChatPendingMessageView {
3434
targetMemberName: string;
35+
retryError: string | null;
36+
retrying: boolean;
37+
onRetry: () => Promise<void>;
3538
}
3639

3740
export interface ChatViewComposerSectionProps {

0 commit comments

Comments
 (0)