Skip to content

Commit bd1b8cd

Browse files
committed
Extract WebChannel agent-message entry service
1 parent fb01ce0 commit bd1b8cd

8 files changed

+329
-36
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
id: extract-webchannel-message-processing-and-storage-adapters
3+
title: Extract WebChannel message processing and storage adapters
4+
status: doing
5+
priority: high
6+
created: 2026-03-28
7+
updated: 2026-03-28
8+
target_release: next
9+
estimate: M
10+
risk: medium
11+
tags:
12+
- work-item
13+
- kanban
14+
- refactor
15+
- web
16+
- modularity
17+
- message-processing
18+
- storage
19+
owner: pi
20+
blocked-by: []
21+
---
22+
23+
# Extract WebChannel message processing and storage adapters
24+
25+
## Summary
26+
27+
Carve the remaining message-processing and storage adapter logic out of
28+
`runtime/src/channels/web.ts` into a focused service/module without changing
29+
`processChat()` behavior, `storeMessage()` persistence semantics, default-agent
30+
identity shaping, thread/media/content-block handling, or the public
31+
`WebChannel` methods used by the handler/runtime surfaces.
32+
33+
This is the next bounded execution slice under:
34+
- `kanban/20-doing/split-webchannel-god-class.md`
35+
36+
after the queued follow-up, server lifecycle/websocket gateway,
37+
SSE/session-broadcast, recovery/runtime-state, message-write, endpoint facade,
38+
agent control-plane, terminal/VNC HTTP, adaptive-card/side-prompt,
39+
peer-message relay, and agent-message entry seams landed.
40+
41+
The goal is to keep `WebChannel` as a thin coordinator while moving:
42+
- `processChat()`
43+
- `storeMessage()`
44+
- any adjacent default-agent identity / persistence adapter glue
45+
46+
behind a narrower, testable seam.
47+
48+
## Scope
49+
50+
Target only the message-processing/storage adapter responsibilities currently
51+
owned by `WebChannel`, including logic around:
52+
53+
- forwarding into `processAgentChat(...)`
54+
- shaping the `storeWebMessage(...)` call context
55+
- preserving default agent id/name identity behavior
56+
- preserving thread/media/content-block and steering/terminal reply semantics
57+
58+
Expected source surfaces:
59+
- `runtime/src/channels/web.ts`
60+
- any new focused service/helper file(s) created for this slice
61+
- `runtime/test/channels/web/`
62+
63+
## Non-goals
64+
65+
- No deeper `web/handlers/agent.ts` decomposition in this slice
66+
- No message-write service behavior changes
67+
- No queue/control-plane changes
68+
- No payload or public API changes
69+
- No UI bundle work in this slice
70+
71+
## Acceptance Criteria
72+
73+
- [ ] Message-processing/storage adapters move behind a focused service/module with a narrower interface than `WebChannel`.
74+
- [ ] Existing behavior remains unchanged for:
75+
- [ ] `processChat()` delegation semantics
76+
- [ ] `storeMessage()` persistence and identity shaping
77+
- [ ] thread/media/content-block / steering / terminal-reply handling
78+
- [ ] public WebChannel method signatures and status behavior relied on by handlers/tests
79+
- [ ] `runtime/src/channels/web.ts` loses another meaningful chunk of adapter glue.
80+
- [ ] Focused tests exist or are strengthened for the extracted seam.
81+
- [ ] Existing relevant `web-channel` integration tests still pass.
82+
- [ ] No new `any` usage is introduced.
83+
84+
## Recommended Path
85+
86+
Extract a dedicated message-processing/storage adapter seam while keeping
87+
`WebChannel.processChat()` and `WebChannel.storeMessage()` as thin delegates.
88+
89+
## Test Plan
90+
91+
- [ ] Add or strengthen focused tests for:
92+
- `processChat()` delegation
93+
- `storeMessage()` identity/persistence shaping
94+
- handler compatibility with the extracted seam
95+
- [ ] Re-run affected integration coverage from:
96+
- `runtime/test/channels/web/web-channel.test.ts`
97+
- `runtime/test/channels/web/web-agent-streaming.test.ts`
98+
- `runtime/test/channels/web/message-write-service.test.ts`
99+
- [ ] Run validation in repair-first order:
100+
1. focused processing/storage tests
101+
2. targeted `web-channel` tests
102+
3. `bun run lint`
103+
4. `bun run typecheck`
104+
105+
## Definition of Done
106+
107+
- [ ] Extracted message-processing/storage seam is mergeable back to `main`.
108+
- [ ] Focused and integration validation are green.
109+
- [ ] Ticket `## Updates` records commands, evidence, and files touched.
110+
- [ ] Parent WebChannel split ticket is updated to reflect the next chosen seam.
111+
112+
## Updates
113+
114+
### 2026-03-28
115+
- Created as the next bounded execution slice under `split-webchannel-god-class` after the agent-message entry seam landed.
116+
- Chosen because `processChat()` and `storeMessage()` remain the last substantive message-processing/storage adapters still living directly on `WebChannel`.
117+
- Intended for the same repair-first loop: focused seam tests first, then extraction, then targeted `web-channel` validation, then lint/typecheck.
118+
- Quality: ★★★★☆ 8/10 (problem: 2, scope: 2, test: 2, deps: 1, risk: 1)
119+
120+
## Links
121+
122+
- `kanban/20-doing/split-webchannel-god-class.md`
123+
- `kanban/40-review/extract-webchannel-agent-message-entry-wrapper.md`
124+
- `kanban/40-review/extract-webchannel-peer-message-relay-wrapper.md`
125+
- `/workspace/notes/piclaw-autoresearch-audit-checklist.md`

kanban/20-doing/split-webchannel-god-class.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ Extract `WebChannel` into a composition of focused services:
7878
- The terminal/VNC HTTP seam then landed behind `runtime/src/channels/web/terminal-vnc-http-service.ts`, moving terminal session, VNC session, and VNC handoff HTTP wrapper glue out of `WebChannel` while preserving auth/CSRF/target-validation behavior and router-facing public methods.
7979
- The adaptive-card/side-prompt seam then landed behind `runtime/src/channels/web/adaptive-card-side-prompt-service.ts`, moving adaptive-card action orchestration and side-prompt request/stream wrappers out of `WebChannel` while preserving payload validation, login/TOTP flows, autoresearch card actions, and router-facing public methods.
8080
- The peer-message relay seam then landed behind `runtime/src/channels/web/agent-peer-message-relay-service.ts`, moving payload validation, target resolution, forwarded request shaping, and normal-path delegation out of `WebChannel` while preserving router-facing `handleAgentPeerMessage()` behavior.
81+
- The agent-message entry seam then landed behind `runtime/src/channels/web/agent-message-entry-service.ts`, moving `chat_jid` parsing/defaulting and `/agent/:agentId/message` forwarding out of `WebChannel` while preserving router-facing behavior.
8182
- Split the next bounded seam into:
82-
- `kanban/20-doing/extract-webchannel-agent-message-entry-wrapper.md`
83-
- Rationale: the `/agent/:agentId/message` entry wrapper remains one of the last cohesive request-entry seams still living on `WebChannel` after the first ten extractions.
83+
- `kanban/20-doing/extract-webchannel-message-processing-and-storage-adapters.md`
84+
- Rationale: `processChat()` and `storeMessage()` remain the last substantive message-processing/storage adapters still living directly on `WebChannel` after the first eleven extractions.
8485
- Quality: ★★★★☆ 8/10 (problem: 2, scope: 2, test: 2, deps: 1, risk: 1)
8586

8687
### 2026-03-27
@@ -111,4 +112,5 @@ Extract `WebChannel` into a composition of focused services:
111112
- `kanban/40-review/extract-webchannel-terminal-and-vnc-http-wrappers.md`
112113
- `kanban/40-review/extract-webchannel-adaptive-card-actions-and-side-prompts.md`
113114
- `kanban/40-review/extract-webchannel-peer-message-relay-wrapper.md`
114-
- `kanban/20-doing/extract-webchannel-agent-message-entry-wrapper.md`
115+
- `kanban/40-review/extract-webchannel-agent-message-entry-wrapper.md`
116+
- `kanban/20-doing/extract-webchannel-message-processing-and-storage-adapters.md`

kanban/20-doing/extract-webchannel-agent-message-entry-wrapper.md renamed to kanban/40-review/extract-webchannel-agent-message-entry-wrapper.md

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
id: extract-webchannel-agent-message-entry-wrapper
33
title: Extract WebChannel agent message entry wrapper
4-
status: doing
4+
status: review
55
priority: high
66
created: 2026-03-28
77
updated: 2026-03-28
@@ -67,15 +67,15 @@ Expected source surfaces:
6767

6868
## Acceptance Criteria
6969

70-
- [ ] Agent-message entry wrapper moves behind a focused service/module with a narrower interface than `WebChannel`.
71-
- [ ] Existing behavior remains unchanged for:
72-
- [ ] `chat_jid` query parsing/defaulting
73-
- [ ] forwarding into the normal agent message handler path
74-
- [ ] request-router-facing public WebChannel method and status codes
75-
- [ ] `runtime/src/channels/web.ts` loses another meaningful chunk of wrapper glue.
76-
- [ ] Focused tests exist or are strengthened for the extracted seam.
77-
- [ ] Existing relevant `web-channel` integration tests still pass.
78-
- [ ] No new `any` usage is introduced.
70+
- [x] Agent-message entry wrapper moves behind a focused service/module with a narrower interface than `WebChannel`.
71+
- [x] Existing behavior remains unchanged for:
72+
- [x] `chat_jid` query parsing/defaulting
73+
- [x] forwarding into the normal agent message handler path
74+
- [x] request-router-facing public WebChannel method and status codes
75+
- [x] `runtime/src/channels/web.ts` loses another meaningful chunk of wrapper glue.
76+
- [x] Focused tests exist or are strengthened for the extracted seam.
77+
- [x] Existing relevant `web-channel` integration tests still pass.
78+
- [x] No new `any` usage is introduced.
7979

8080
## Recommended Path
8181

@@ -84,32 +84,42 @@ public `WebChannel.handleAgentMessage()` method as a thin delegate.
8484

8585
## Test Plan
8686

87-
- [ ] Add or strengthen focused tests for:
87+
- [x] Add or strengthen focused tests for:
8888
- chat-jid parsing/default behavior
8989
- delegation into the shared agent message handler
9090
- router-facing method stability
91-
- [ ] Re-run affected integration coverage from:
91+
- [x] Re-run affected integration coverage from:
9292
- `runtime/test/channels/web/web-channel.test.ts`
9393
- `runtime/test/channels/web/http-dispatch-agent.test.ts`
94-
- [ ] Run validation in repair-first order:
94+
- [x] Run validation in repair-first order:
9595
1. focused entry-wrapper tests
9696
2. targeted `web-channel` tests
9797
3. `bun run lint`
9898
4. `bun run typecheck`
9999

100100
## Definition of Done
101101

102-
- [ ] Extracted agent-message entry seam is mergeable back to `main`.
103-
- [ ] Focused and integration validation are green.
104-
- [ ] Ticket `## Updates` records commands, evidence, and files touched.
105-
- [ ] Parent WebChannel split ticket is updated to reflect the next chosen seam.
102+
- [x] Extracted agent-message entry seam is mergeable back to `main`.
103+
- [x] Focused and integration validation are green.
104+
- [x] Ticket `## Updates` records commands, evidence, and files touched.
105+
- [x] Parent WebChannel split ticket is updated to reflect the next chosen seam.
106106

107107
## Updates
108108

109109
### 2026-03-28
110-
- Created as the next bounded execution slice under `split-webchannel-god-class` after the peer-message relay seam landed.
111-
- Chosen because the `/agent/:agentId/message` entry wrapper remains one of the last cohesive request-entry seams still living directly on `WebChannel`.
112-
- Intended for the same repair-first loop: focused seam tests first, then extraction, then targeted `web-channel` validation, then lint/typecheck.
110+
- Lane change: `20-doing``40-review` after landing the slice on `main`.
111+
- Extracted the wrapper into `runtime/src/channels/web/agent-message-entry-service.ts` and switched `WebChannel.handleAgentMessage()` to a thin delegate without changing the public router-facing method.
112+
- Added focused seam coverage in `runtime/test/channels/web/agent-message-entry-service.test.ts` and `runtime/test/channels/web/web-channel-agent-message-entry-delegation.test.ts`, and strengthened `runtime/test/channels/web/http-dispatch-agent.test.ts` so the dynamic `/agent/:id/message` route also proves the routed request preserves `chat_jid`.
113+
- Validation evidence:
114+
- `bun test runtime/test/channels/web/agent-message-entry-service.test.ts runtime/test/channels/web/web-channel-agent-message-entry-delegation.test.ts runtime/test/channels/web/http-dispatch-agent.test.ts runtime/test/channels/web/web-channel.test.ts`
115+
- `bun run lint`
116+
- `bun run typecheck`
117+
- `bun run check:stale-dist`
118+
- Follow-up cleanup removed direct constructor-owned wiring for the extracted seam: `WebChannel.handleAgentMessage()` now resolves the entry service through a module helper instead of keeping a dedicated field/init pair on the coordinator.
119+
- Final tidy-up removed redundant `async`/`await` from the thin delegates in both `runtime/src/channels/web.ts` and `runtime/src/channels/web/agent-message-entry-service.ts`, keeping the Promise-returning wrapper path minimal without changing behavior.
120+
- Files touched: `runtime/src/channels/web.ts`, `runtime/src/channels/web/agent-message-entry-service.ts`, `runtime/test/channels/web/agent-message-entry-service.test.ts`, `runtime/test/channels/web/web-channel-agent-message-entry-delegation.test.ts`, `runtime/test/channels/web/http-dispatch-agent.test.ts`.
121+
- Next bounded seam split out explicitly instead of widening scope in-place:
122+
- `kanban/20-doing/extract-webchannel-message-processing-and-storage-adapters.md`
113123
- Quality: ★★★★☆ 8/10 (problem: 2, scope: 2, test: 2, deps: 1, risk: 1)
114124

115125
## Links

runtime/src/channels/web.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,7 @@ import {
2525
import type { WebChannelLike } from "./web/web-channel-contracts.js";
2626
import { RequestRouterService } from "./web/request-router-service.js";
2727
import { handlePost as handlePostRequest } from "./web/handlers/posts.js";
28-
import {
29-
handleAgentMessage as handleAgentMessageRequest,
30-
processChat as processAgentChat,
31-
} from "./web/handlers/agent.js";
28+
import { processChat as processAgentChat } from "./web/handlers/agent.js";
3229
import { WebSessionBroadcastService } from "./web/session-broadcast-service.js";
3330
import { ResponseService } from "./web/http/response-service.js";
3431
import {
@@ -73,6 +70,7 @@ import {
7370
WebAgentPeerMessageRelayService,
7471
type WebAgentPeerMessageRelayChannelLike,
7572
} from "./web/agent-peer-message-relay-service.js";
73+
import { getWebAgentMessageEntryService } from "./web/agent-message-entry-service.js";
7674
import { TerminalSessionService } from "./web/terminal/terminal-session-service.js";
7775
import { VncSessionService } from "./web/vnc/vnc-session-service.js";
7876
import { RemoteInteropService } from "../remote/service.js";
@@ -602,11 +600,7 @@ export class WebChannel implements WebChannelLike {
602600
return await getAdaptiveCardSidePromptService(this).handleAgentSidePromptStream(req);
603601
}
604602

605-
async handleAgentMessage(req: Request, pathname: string): Promise<Response> {
606-
const url = new URL(req.url);
607-
const chatJid = url.searchParams.get("chat_jid")?.trim() || DEFAULT_CHAT_JID;
608-
return handleAgentMessageRequest(this, req, pathname, chatJid, DEFAULT_AGENT_ID);
609-
}
603+
handleAgentMessage(req: Request, pathname: string): Promise<Response> { return getWebAgentMessageEntryService(this, { defaultChatJid: DEFAULT_CHAT_JID, defaultAgentId: DEFAULT_AGENT_ID }).handleAgentMessage(req, pathname); }
610604

611605
async processChat(chatJid: string, agentId: string, threadRootId?: number | null): Promise<void> {
612606
return processAgentChat(this, chatJid, agentId, threadRootId ?? undefined);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* channels/web/agent-message-entry-service.ts – WebChannel agent-message entry wrapper seam.
3+
*
4+
* Owns the thin `/agent/:agentId/message` request-entry wrapper that preserves
5+
* existing `chat_jid` query parsing/defaulting and forwards into the shared
6+
* agent-message handler path without changing router-facing WebChannel APIs.
7+
*/
8+
9+
import { handleAgentMessage as handleAgentMessageRequest } from "./handlers/agent.js";
10+
import type { WebChannelLike } from "./web-channel-contracts.js";
11+
12+
export interface WebAgentMessageEntryServiceOptions {
13+
defaultChatJid: string;
14+
defaultAgentId: string;
15+
forwardAgentMessageRequest(req: Request, pathname: string, chatJid: string, agentId: string): Promise<Response>;
16+
}
17+
18+
export function createWebAgentMessageEntryService(
19+
channel: WebChannelLike,
20+
defaults: { defaultChatJid: string; defaultAgentId: string },
21+
): WebAgentMessageEntryService {
22+
return new WebAgentMessageEntryService({
23+
defaultChatJid: defaults.defaultChatJid,
24+
defaultAgentId: defaults.defaultAgentId,
25+
forwardAgentMessageRequest: (req, pathname, chatJid, agentId) =>
26+
handleAgentMessageRequest(channel, req, pathname, chatJid, agentId),
27+
});
28+
}
29+
30+
export function getWebAgentMessageEntryService(
31+
channel: WebChannelLike & { agentMessageEntryService?: WebAgentMessageEntryService },
32+
defaults: { defaultChatJid: string; defaultAgentId: string },
33+
): WebAgentMessageEntryService {
34+
return channel.agentMessageEntryService ?? createWebAgentMessageEntryService(channel, defaults);
35+
}
36+
37+
export class WebAgentMessageEntryService {
38+
constructor(private readonly options: WebAgentMessageEntryServiceOptions) {}
39+
40+
handleAgentMessage(req: Request, pathname: string): Promise<Response> {
41+
const chatJid = this.resolveChatJid(req);
42+
return this.options.forwardAgentMessageRequest(req, pathname, chatJid, this.options.defaultAgentId);
43+
}
44+
45+
private resolveChatJid(req: Request): string {
46+
return new URL(req.url).searchParams.get("chat_jid")?.trim() || this.options.defaultChatJid;
47+
}
48+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { WebAgentMessageEntryService } from "../../../src/channels/web/agent-message-entry-service.js";
4+
5+
describe("WebAgentMessageEntryService", () => {
6+
test("parses chat_jid and falls back to the default chat before forwarding", async () => {
7+
const calls: Array<{ req: Request; pathname: string; chatJid: string; agentId: string }> = [];
8+
const service = new WebAgentMessageEntryService({
9+
defaultChatJid: "web:default",
10+
defaultAgentId: "default",
11+
forwardAgentMessageRequest: async (req, pathname, chatJid, agentId) => {
12+
calls.push({ req, pathname, chatJid, agentId });
13+
return Response.json({ pathname, chat_jid: chatJid, agent_id: agentId }, { status: 201 });
14+
},
15+
});
16+
17+
const explicitRequest = new Request("https://example.com/agent/custom/message?chat_jid=web%3Abranch", { method: "POST" });
18+
const explicitResponse = await service.handleAgentMessage(explicitRequest, "/agent/custom/message");
19+
expect(explicitResponse.status).toBe(201);
20+
expect(await explicitResponse.json()).toEqual({
21+
pathname: "/agent/custom/message",
22+
chat_jid: "web:branch",
23+
agent_id: "default",
24+
});
25+
26+
const fallbackRequest = new Request("https://example.com/agent/default/message?chat_jid=%20%20", { method: "POST" });
27+
const fallbackResponse = await service.handleAgentMessage(fallbackRequest, "/agent/default/message");
28+
expect(fallbackResponse.status).toBe(201);
29+
expect(await fallbackResponse.json()).toEqual({
30+
pathname: "/agent/default/message",
31+
chat_jid: "web:default",
32+
agent_id: "default",
33+
});
34+
35+
expect(calls).toHaveLength(2);
36+
expect(calls[0]).toEqual({
37+
req: explicitRequest,
38+
pathname: "/agent/custom/message",
39+
chatJid: "web:branch",
40+
agentId: "default",
41+
});
42+
expect(calls[1]).toEqual({
43+
req: fallbackRequest,
44+
pathname: "/agent/default/message",
45+
chatJid: "web:default",
46+
agentId: "default",
47+
});
48+
});
49+
50+
test("returns the forwarded response unchanged", async () => {
51+
const forwardedResponse = new Response("accepted", { status: 202 });
52+
const service = new WebAgentMessageEntryService({
53+
defaultChatJid: "web:default",
54+
defaultAgentId: "default",
55+
forwardAgentMessageRequest: async () => forwardedResponse,
56+
});
57+
58+
const response = await service.handleAgentMessage(
59+
new Request("https://example.com/agent/default/message", { method: "POST" }),
60+
"/agent/default/message",
61+
);
62+
63+
expect(response).toBe(forwardedResponse);
64+
expect(response.status).toBe(202);
65+
expect(await response.text()).toBe("accepted");
66+
});
67+
});

0 commit comments

Comments
 (0)