Merge remote-tracking branch 'origin/feat/web' into feat/web

This commit is contained in:
qer 2026-06-11 13:52:21 +08:00
commit b3161a08ad
15 changed files with 496 additions and 23 deletions

View file

@ -16,8 +16,10 @@
// const appEvents = projector.project(rawType, payload, sessionId);
// // call reset() when re-subscribing / resyncing a session
import type { AppEvent, AppInFlightTurn, AppMessage, AppSessionUsage } from '../types';
import type { AppEvent, AppInFlightTurn, AppMessage, AppMessageContent, AppSessionUsage } from '../types';
import { i18n } from '../../i18n';
import { toAppMessageContent } from './mappers';
import type { WireMessageContent } from './wire';
// ---------------------------------------------------------------------------
// Internal helpers
@ -131,6 +133,31 @@ function startAssistantMessage(state: SessionState, sessionId: string, promptId:
return msg;
}
function startUserMessage(
state: SessionState,
sessionId: string,
promptId: string,
userMessageId: string,
content: AppMessageContent[],
createdAt: string,
): AppMessage {
const msg: AppMessage = {
id: userMessageId,
sessionId,
role: 'user',
content,
createdAt,
promptId,
};
state.messages.push(msg);
return msg;
}
function toAppPromptContent(raw: unknown): AppMessageContent[] {
if (!Array.isArray(raw)) return [];
return raw.map((part) => toAppMessageContent(part as WireMessageContent));
}
/**
* Append a streamed text/thinking delta in stream order: continue the LAST
* content part when it has the same type, otherwise open a NEW part at the
@ -365,6 +392,26 @@ export function createAgentProjector(): AgentProjector {
break;
}
// -----------------------------------------------------------------------
case 'prompt.submitted': {
const promptId: string | undefined = p?.promptId;
const userMessageId: string | undefined = p?.userMessageId;
if (!promptId || !userMessageId) break;
const content = toAppPromptContent(p?.content);
if (content.length === 0) break;
s.currentPromptId = promptId;
const msg = startUserMessage(
s,
sessionId,
promptId,
userMessageId,
content,
typeof p?.createdAt === 'string' ? p.createdAt : new Date().toISOString(),
);
out.push({ type: 'messageCreated', message: cloneMessage(msg) });
break;
}
// -----------------------------------------------------------------------
case 'turn.started': {
// Bind turnId → promptId. Generate a synthetic one if none was pre-bound.
@ -855,6 +902,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([
'tool.progress',
'tool.result',
'agent.status.updated',
'prompt.submitted',
'prompt.completed',
'session.meta.updated',
'compaction.started',

View file

@ -130,7 +130,7 @@ function toAppImageSource(src: WireImageSource): ImageSource {
return { kind: 'url', url: src.url };
}
function toAppMessageContent(wire: WireMessageContent): AppMessageContent {
export function toAppMessageContent(wire: WireMessageContent): AppMessageContent {
switch (wire.type) {
case 'text':
return { type: 'text', text: wire.text };

View file

@ -123,3 +123,36 @@ describe('multi-segment thinking', () => {
});
});
});
describe('prompt.submitted projection', () => {
it('creates the user message for a prompt sent by another client', () => {
const state = play([
[
'prompt.submitted',
{
promptId: 'prompt_1',
userMessageId: 'msg_user_1',
status: 'running',
content: [{ type: 'text', text: 'hello from another client' }],
createdAt: '2026-06-11T00:00:00.000Z',
},
],
['turn.started', { turnId: 1 }],
['turn.step.started', { turnId: 1 }],
['assistant.delta', { delta: 'received' }],
['turn.step.completed', { turnId: 1 }],
['turn.ended', { turnId: 1, reason: 'completed' }],
]);
const messages = state.messagesBySession[SESSION]!;
expect(messages[0]).toMatchObject({
id: 'msg_user_1',
sessionId: SESSION,
role: 'user',
promptId: 'prompt_1',
content: [{ type: 'text', text: 'hello from another client' }],
createdAt: '2026-06-11T00:00:00.000Z',
});
expect(messages.find((message) => message.role === 'assistant')?.promptId).toBe('prompt_1');
});
});

View file

@ -7,6 +7,7 @@ import { isVolatileEventType, type Event, type SessionCursor } from '@moonshot-a
import { IEventService } from '@moonshot-ai/services';
import { IEnvironmentService, ILogService } from '@moonshot-ai/services';
import { IConnectionRegistry } from './connectionRegistry';
import { InFlightTurnTracker } from './inFlightTurnTracker';
import { ISessionClientsService } from './sessionClients';
import { SessionEventJournal } from './sessionEventJournal';
@ -47,6 +48,7 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
@IEventService eventService: IEventService,
@ILogService private readonly logger: ILogService,
@ISessionClientsService private readonly sessionClients: ISessionClientsService,
@IConnectionRegistry private readonly connectionRegistry: IConnectionRegistry,
@IEnvironmentService env: IEnvironmentService,
) {
super();
@ -110,7 +112,10 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
}
if (this._store.isDisposed) return;
for (const conn of this.sessionClients.getConnections(sid)) {
const targets = isGlobalSessionEvent(evType)
? this.connectionRegistry.values()
: this.sessionClients.getConnections(sid);
for (const conn of targets) {
conn.send(envelope);
}
}
@ -234,6 +239,10 @@ function extractSessionId(event: Event): string | undefined {
return undefined;
}
function isGlobalSessionEvent(type: string): boolean {
return type === 'event.session.created';
}
/** Session ids are ULID-ish, but never trust an id used as a path segment. */
function sanitizeFileName(sid: string): string {
return sid.replace(/[^A-Za-z0-9._-]/g, '_');

View file

@ -600,6 +600,38 @@ describe('Prompt queue and steer routes', () => {
});
describe('Prompt lifecycle: WS receives events + synthesized prompt.completed (W7.2)', () => {
it('broadcasts prompt.submitted events to session subscribers', async () => {
const r = await bootDaemon();
const sid = await createSession(r);
const { ws, received } = await openSubscriber(r, sid);
const eventBus = r.services.invokeFunction((a) => a.get(IEventService));
eventBus.publish({
type: 'prompt.submitted',
agentId: 'main',
sessionId: sid,
promptId: 'prompt_submit_test',
userMessageId: 'msg_submit_test',
status: 'running',
content: [{ type: 'text', text: 'hello from client a' }],
createdAt: '2026-06-11T00:00:00.000Z',
});
const submittedFrame = await waitFor(
received,
(f) => f['type'] === 'prompt.submitted',
2000,
);
expect(submittedFrame['session_id']).toBe(sid);
expect(submittedFrame['payload']).toMatchObject({
promptId: 'prompt_submit_test',
userMessageId: 'msg_submit_test',
content: [{ type: 'text', text: 'hello from client a' }],
});
ws.close();
});
it('synthesizes prompt.completed end-to-end through bus → observer → WS', async () => {
const r = await bootDaemon();
const sid = await createSession(r);

View file

@ -74,6 +74,34 @@ class FakeSessionClients implements ISessionClientsServiceT {
}
}
class FakeConnectionRegistry {
readonly _serviceBrand: undefined;
private readonly conns = new Map<string, WsConnection>();
constructor(connections: WsConnection[] = []) {
for (const conn of connections) this.conns.set(conn.id, conn);
}
add(conn: WsConnection): void {
this.conns.set(conn.id, conn);
}
remove(connId: string): void {
this.conns.delete(connId);
}
get(connId: string): WsConnection | undefined {
return this.conns.get(connId);
}
values(): Iterable<WsConnection> {
return this.conns.values();
}
closeAll(): void {
this.conns.clear();
}
size(): number {
return this.conns.size;
}
}
function fakeConn(id = 'conn_x'): { id: string; sent: unknown[]; send(m: unknown): void } & WsConnection {
const sent: unknown[] = [];
return {
@ -174,7 +202,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
clients.subscribe(c2, 'sid_test');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), makeEnv());
bus.publish({ type: 'fake.x', sessionId: 'sid_test', agentId: 'main' } as unknown as Event);
bus.publish({ type: 'fake.y', sessionId: 'sid_test', agentId: 'main' } as unknown as Event);
await broadcast._drainForTest('sid_test');
@ -201,7 +229,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
clients.subscribe(cB, 'sid_b');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), makeEnv());
bus.publish({ type: 'e1', sessionId: 'sid_a', agentId: 'main' } as unknown as Event);
bus.publish({ type: 'e1', sessionId: 'sid_b', agentId: 'main' } as unknown as Event);
bus.publish({ type: 'e2', sessionId: 'sid_a', agentId: 'main' } as unknown as Event);
@ -226,7 +254,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
clients.subscribe(onOther, 'sid_other');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), makeEnv());
bus.publish({ type: 'evt', sessionId: 'sid_a', agentId: 'main' } as unknown as Event);
await broadcast._drainForTest('sid_a');
expect(onA.sent.length).toBe(1);
@ -235,6 +263,35 @@ describe('WSBroadcastService (WS transport pump)', () => {
bus.dispose();
});
it('broadcasts session.created to every live connection', async () => {
const clients = new FakeSessionClients();
const subscribed = fakeConn('conn_subscribed');
const listOnly = fakeConn('conn_list_only');
clients.subscribe(subscribed, 'sid_new');
const bus = new EventService();
const broadcast = new WSBroadcastService(
bus,
testLogger,
clients,
new FakeConnectionRegistry([subscribed, listOnly]),
makeEnv(),
);
bus.publish({
type: 'event.session.created',
sessionId: 'sid_new',
agentId: 'main',
session: { id: 'sid_new' },
} as unknown as Event);
await broadcast._drainForTest('sid_new');
expect(subscribed.sent).toHaveLength(1);
expect(listOnly.sent).toHaveLength(1);
expect((listOnly.sent[0] as { type: string }).type).toBe('event.session.created');
broadcast.dispose();
bus.dispose();
});
it('drops events without a sessionId / session_id and warns', () => {
const clients = new FakeSessionClients();
const c = fakeConn();
@ -242,7 +299,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
const warnSpy = vi.spyOn(testLogger, 'warn');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), makeEnv());
bus.publish({ type: 'no_sid' } as unknown as Event);
expect(c.sent.length).toBe(0);
@ -256,7 +313,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
const c = fakeConn();
clients.subscribe(c, 'sid_x');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), makeEnv());
broadcast.dispose();
bus.publish({ type: 'late', sessionId: 'sid_x', agentId: 'main' } as unknown as Event);
await new Promise((r) => setTimeout(r, 10));
@ -269,7 +326,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
const c = fakeConn();
clients.subscribe(c, 'sid_test');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), makeEnv());
for (let i = 0; i < 5; i++) {
bus.publish({ type: `e${i}`, sessionId: 'sid_test', agentId: 'main' } as unknown as Event);
}
@ -284,7 +341,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
it('getBufferedSince forces a resync for a cursor ahead of the journal (stale v1 cursor)', async () => {
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, new FakeSessionClients(), makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, new FakeSessionClients(), new FakeConnectionRegistry(), makeEnv());
const replay = await broadcast.getBufferedSince('sid_new', { seq: 5 });
expect(replay.events).toEqual([]);
expect(replay.resyncRequired).toBe('epoch_changed');
@ -295,7 +352,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
it('getBufferedSince forces a resync on epoch mismatch', async () => {
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, new FakeSessionClients(), makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, new FakeSessionClients(), new FakeConnectionRegistry(), makeEnv());
bus.publish({ type: 'e', sessionId: 'sid_e', agentId: 'main' } as unknown as Event);
await broadcast._drainForTest('sid_e');
const replay = await broadcast.getBufferedSince('sid_e', { seq: 0, epoch: 'ep_other' });
@ -306,7 +363,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
it('seq and epoch survive a daemon restart (journal recovery) and serve replay from disk', async () => {
const bus1 = new EventService();
const b1 = new WSBroadcastService(bus1, testLogger, new FakeSessionClients(), makeEnv());
const b1 = new WSBroadcastService(bus1, testLogger, new FakeSessionClients(), new FakeConnectionRegistry(), makeEnv());
for (let i = 0; i < 3; i++) {
bus1.publish({ type: `e${i}`, sessionId: 'sid_p', agentId: 'main' } as unknown as Event);
}
@ -318,7 +375,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
await new Promise((r) => setTimeout(r, 50));
const bus2 = new EventService();
const b2 = new WSBroadcastService(bus2, testLogger, new FakeSessionClients(), makeEnv());
const b2 = new WSBroadcastService(bus2, testLogger, new FakeSessionClients(), new FakeConnectionRegistry(), makeEnv());
const after = await b2.getCursor('sid_p');
expect(after.seq).toBe(3);
expect(after.epoch).toBe(before.epoch);
@ -341,7 +398,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
const c = fakeConn();
clients.subscribe(c, 'sid_v');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), makeEnv());
bus.publish({
type: 'turn.started',
@ -392,7 +449,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
it('getSnapshotState clears the in-flight turn after turn.ended', async () => {
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, new FakeSessionClients(), makeEnv());
const broadcast = new WSBroadcastService(bus, testLogger, new FakeSessionClients(), new FakeConnectionRegistry(), makeEnv());
bus.publish({
type: 'turn.started',
sessionId: 'sid_t',
@ -490,7 +547,7 @@ describe('ApprovalService (broadcasts + resolve-by-approval_id)', () => {
const conn = fakeConn('conn_subscriber');
clients.subscribe(conn, 'sess_1');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, tmpEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), tmpEnv());
const broker = new ApprovalService(testLogger, bus);
return { broker, bus, broadcast, clients, conn };
}
@ -611,7 +668,7 @@ describe('QuestionService (broadcasts + dismiss)', () => {
const conn = fakeConn('conn_q_subscriber');
clients.subscribe(conn, 's');
const bus = new EventService();
const broadcast = new WSBroadcastService(bus, testLogger, clients, tmpEnv());
const broadcast = new WSBroadcastService(bus, testLogger, clients, new FakeConnectionRegistry(), tmpEnv());
const broker = new QuestionService(testLogger, bus);
return { broker, bus, broadcast, clients, conn };
}

View file

@ -32,6 +32,7 @@ import { join } from 'node:path';
import { pino } from 'pino';
import { ErrorCode, sessionSchema, undoSessionResponseSchema } from '@moonshot-ai/protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
import { IRestGateway, startDaemon, type RunningDaemon } from '../src';
@ -83,6 +84,57 @@ function envelopeOf<T>(body: unknown): { code: number; msg: string; data: T | nu
return body as { code: number; msg: string; data: T | null; request_id: string; details?: unknown };
}
function wsDataToString(data: unknown): string {
if (typeof data === 'string') return data;
if (Buffer.isBuffer(data)) return data.toString('utf8');
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
return JSON.stringify(data);
}
async function openSessionListListener(r: RunningDaemon): Promise<{
ws: WebSocket;
received: Record<string, unknown>[];
}> {
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
const received: Record<string, unknown>[] = [];
const ws = await new Promise<WebSocket>((resolve, reject) => {
const sock = new WebSocket(wsUrl);
sock.on('message', (data) => {
try {
received.push(JSON.parse(wsDataToString(data)) as Record<string, unknown>);
} catch {
// ignore
}
});
sock.once('open', () => resolve(sock));
sock.once('error', reject);
});
await waitFor(received, (f) => f['type'] === 'server_hello');
ws.send(
JSON.stringify({
type: 'client_hello',
id: 'h1',
payload: { client_id: 'session-list-test', subscriptions: [] },
}),
);
await waitFor(received, (f) => f['type'] === 'ack' && f['id'] === 'h1');
return { ws, received };
}
async function waitFor(
received: Record<string, unknown>[],
pred: (f: Record<string, unknown>) => boolean,
timeoutMs = 2000,
): Promise<Record<string, unknown>> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const found = received.find(pred);
if (found !== undefined) return found;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timed out waiting for frame; got ${JSON.stringify(received)}`);
}
describe('POST /api/v1/sessions — create', () => {
it('returns a Session payload with snake_case + ISO Z timestamps', async () => {
const r = await bootDaemon();
@ -105,6 +157,36 @@ describe('POST /api/v1/sessions — create', () => {
expect(session.id.length).toBeGreaterThan(0);
});
it('broadcasts event.session.created to connected clients without a session subscription', async () => {
const r = await bootDaemon();
const { ws, received } = await openSessionListListener(r);
const cwd = join(tmpDir, 'workspace-create-broadcast');
const res = await appOf(r).inject({
method: 'POST',
url: '/api/v1/sessions',
payload: { metadata: { cwd }, title: 'created via ws test' },
});
const env = envelopeOf<{ id: string }>(res.json());
expect(env.code).toBe(0);
expect(env.data).not.toBeNull();
const frame = await waitFor(
received,
(f) => f['type'] === 'event.session.created',
);
expect(frame['session_id']).toBe(env.data!.id);
expect(frame['payload']).toMatchObject({
session: {
id: env.data!.id,
title: 'created via ws test',
metadata: { cwd },
},
});
ws.close();
});
it('rejects a body missing metadata.cwd with code 40001 + details', async () => {
const r = await bootDaemon();
const res = await appOf(r).inject({

View file

@ -102,4 +102,54 @@ describe('events / display re-exports', () => {
expect(parsed.agentId).toBe('agent_1');
expect(parsed.sessionId).toBe('sess_1');
});
it('validates prompt.submitted events', () => {
const parsed = eventSchema.parse({
type: 'prompt.submitted',
agentId: 'main',
sessionId: 'sess_1',
promptId: 'prompt_1',
userMessageId: 'msg_1',
status: 'running',
content: [{ type: 'text', text: 'hello' }],
createdAt: '2026-06-11T00:00:00.000Z',
});
expect(parsed.type).toBe('prompt.submitted');
expect((parsed as { promptId: string }).promptId).toBe('prompt_1');
});
it('validates event.session.created events', () => {
const parsed = eventSchema.parse({
type: 'event.session.created',
agentId: 'main',
sessionId: 'sess_1',
session: {
id: 'sess_1',
workspace_id: 'wd_project_123456abcdef',
title: 'Created session',
created_at: '2026-06-11T00:00:00.000Z',
updated_at: '2026-06-11T00:00:00.000Z',
status: 'idle',
metadata: { cwd: '/tmp/project' },
agent_config: { model: 'kimi-k2' },
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_cost_usd: 0,
context_tokens: 0,
context_limit: 0,
turn_count: 0,
},
permission_rules: [],
message_count: 0,
last_seq: 0,
},
});
expect(parsed.type).toBe('event.session.created');
expect((parsed as { session: { id: string } }).session.id).toBe('sess_1');
});
});

View file

@ -1,6 +1,9 @@
import { z } from 'zod';
import { ToolInputDisplaySchema, type ToolInputDisplay } from './display';
import { messageContentSchema, type MessageContent } from './message';
import { sessionSchema, type Session } from './session';
import { isoDateTimeSchema } from './time';
export interface TokenUsage {
readonly inputOther: number;
@ -306,6 +309,11 @@ export interface SessionMetaUpdatedEvent {
readonly patch?: Record<string, unknown>;
}
export interface SessionCreatedEvent {
readonly type: 'event.session.created';
readonly session: Session;
}
export interface GoalUpdatedEvent {
readonly type: 'goal.updated';
readonly snapshot: GoalSnapshot | null;
@ -515,6 +523,15 @@ export interface CronFiredEvent {
readonly prompt: string;
}
export interface PromptSubmittedEvent {
readonly type: 'prompt.submitted';
readonly promptId: string;
readonly userMessageId: string;
readonly status: 'running' | 'queued';
readonly content: readonly MessageContent[];
readonly createdAt: string;
}
export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed';
export interface ToolListUpdatedEvent {
@ -541,6 +558,7 @@ export type AgentEvent =
| WarningEvent
| AgentStatusUpdatedEvent
| SessionMetaUpdatedEvent
| SessionCreatedEvent
| GoalUpdatedEvent
| SkillActivatedEvent
| TurnStartedEvent
@ -569,7 +587,8 @@ export type AgentEvent =
| CompactionCompletedEvent
| BackgroundTaskStartedEvent
| BackgroundTaskTerminatedEvent
| CronFiredEvent;
| CronFiredEvent
| PromptSubmittedEvent;
export type Event = AgentEvent & { agentId: string; sessionId: string };
@ -881,6 +900,11 @@ export const sessionMetaUpdatedEventSchema = z.object({
patch: z.record(z.string(), z.unknown()).optional(),
}) satisfies z.ZodType<SessionMetaUpdatedEvent>;
export const sessionCreatedEventSchema = z.object({
type: z.literal('event.session.created'),
session: sessionSchema,
}) satisfies z.ZodType<SessionCreatedEvent>;
export const goalUpdatedEventSchema = z.object({
type: z.literal('goal.updated'),
snapshot: goalSnapshotSchema.nullable(),
@ -1090,6 +1114,15 @@ export const cronFiredEventSchema = z.object({
prompt: z.string(),
}) satisfies z.ZodType<CronFiredEvent>;
export const promptSubmittedEventSchema = z.object({
type: z.literal('prompt.submitted'),
promptId: z.string(),
userMessageId: z.string(),
status: z.enum(['running', 'queued']),
content: z.array(messageContentSchema),
createdAt: isoDateTimeSchema,
}) satisfies z.ZodType<PromptSubmittedEvent>;
export const toolListUpdatedReasonSchema = z.enum([
'mcp.connected',
'mcp.disconnected',
@ -1120,6 +1153,7 @@ export const agentEventSchema = z.discriminatedUnion('type', [
warningEventSchema,
agentStatusUpdatedEventSchema,
sessionMetaUpdatedEventSchema,
sessionCreatedEventSchema,
goalUpdatedEventSchema,
skillActivatedEventSchema,
turnStartedEventSchema,
@ -1149,6 +1183,7 @@ export const agentEventSchema = z.discriminatedUnion('type', [
backgroundTaskStartedEventSchema,
backgroundTaskTerminatedEventSchema,
cronFiredEventSchema,
promptSubmittedEventSchema,
]) satisfies z.ZodType<AgentEvent>;
export const eventSchema = agentEventSchema.and(

View file

@ -149,6 +149,7 @@ export type {
SyntheticPromptAbortedEvent,
SyntheticPromptCompletedEvent,
SyntheticPromptSteeredEvent,
SyntheticPromptSubmittedEvent,
} from './prompt/prompt';
export { PromptService } from './prompt/promptService';

View file

@ -71,6 +71,7 @@ import type { Event } from '@moonshot-ai/agent-core/base/common/event';
import type {
PromptListResponse,
PromptSubmission,
PromptStatus,
PromptSteerResult,
PromptSubmitResult,
} from '@moonshot-ai/protocol';
@ -240,6 +241,17 @@ export class PromptAlreadyCompletedError extends Error {
}
}
export interface SyntheticPromptSubmittedEvent {
readonly type: 'prompt.submitted';
readonly agentId: string;
readonly sessionId: string;
readonly promptId: string;
readonly userMessageId: string;
readonly status: PromptStatus;
readonly content: PromptSubmission['content'];
readonly createdAt: string;
}
/**
* `prompt.completed` synthetic event shape. Matches the agent-core `Event`
* type contract (`AgentEvent & { agentId, sessionId }`) so it flows through

View file

@ -36,6 +36,7 @@ import {
type SyntheticPromptCompletedEvent,
type SyntheticPromptAbortedEvent,
type SyntheticPromptSteeredEvent,
type SyntheticPromptSubmittedEvent,
} from './prompt';
const MAIN_AGENT_ID = 'main';
@ -317,11 +318,15 @@ export class PromptService
const existing = this._active.get(sid);
if (existing !== undefined && !existing.completed && !existing.aborted) {
this._enqueue(sid, state);
return toPromptItem(state, 'queued');
const item = toPromptItem(state, 'queued');
this._publishSubmitted(sid, item);
return item;
}
await this._startPrompt(sid, state);
return toPromptItem(state, 'running');
const item = toPromptItem(state, 'running');
this._publishSubmitted(sid, item);
return item;
}
async steer(sid: string, promptIds: readonly string[]): Promise<PromptSteerResult> {
@ -414,6 +419,20 @@ export class PromptService
}
}
private _publishSubmitted(sid: string, item: PromptSubmitResult): void {
const event: SyntheticPromptSubmittedEvent = {
type: 'prompt.submitted',
agentId: MAIN_AGENT_ID,
sessionId: sid,
promptId: item.prompt_id,
userMessageId: item.user_message_id,
status: item.status,
content: item.content,
createdAt: item.created_at,
};
this.eventService.publish(event);
}
async abort(sid: string, pid: string): Promise<PromptAbortResult> {
await this._requireSession(sid);
const state = this._active.get(sid);

View file

@ -30,6 +30,7 @@ import {
} from '@moonshot-ai/protocol';
import { ICoreProcessService } from '../coreProcess/coreProcess';
import { IEventService } from '../event/event';
import { toProtocolMessage } from '../message/message';
import { IPromptService, type AgentStatePatch } from '../prompt/prompt';
import {
@ -107,6 +108,7 @@ export class SessionService extends Disposable implements ISessionService {
constructor(
@ICoreProcessService private readonly core: ICoreProcessService,
@IEventService private readonly eventService: IEventService,
@IInstantiationService
private readonly instantiation: IInstantiationService,
) {
@ -131,7 +133,7 @@ export class SessionService extends Disposable implements ISessionService {
}
const meta = await this.tryGetMeta(summary.id);
const session = toProtocolSession(summary, meta);
this._onDidCreate.fire({ session });
this.emitCreated(session);
return session;
}
@ -239,7 +241,7 @@ export class SessionService extends Disposable implements ISessionService {
});
const meta = await this.tryGetMeta(summary.id);
const session = toProtocolSession(summary, meta);
this._onDidCreate.fire({ session });
this.emitCreated(session);
return session;
}
@ -301,10 +303,20 @@ export class SessionService extends Disposable implements ISessionService {
});
const meta = await this.tryGetMeta(summary.id);
const session = toProtocolSession(summary, meta);
this._onDidCreate.fire({ session });
this.emitCreated(session);
return session;
}
private emitCreated(session: Session): void {
this._onDidCreate.fire({ session });
this.eventService.publish({
type: 'event.session.created',
agentId: 'main',
sessionId: session.id,
session,
});
}
async getStatus(id: string): Promise<SessionStatusResponse> {
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);

View file

@ -346,6 +346,36 @@ describe('PromptService.submit', () => {
]);
});
it('publishes prompt.submitted when a prompt starts running', async () => {
const { bridge } = makeBridge();
const { bus, events } = makeBus();
const impl = newSvc(bridge, bus);
const body = mkBody({ content: [{ type: 'text', text: 'hello from client a' }] });
const result = await impl.submit(SID, body);
const submitted = events.find((event) => event.type === 'prompt.submitted') as
| {
type: 'prompt.submitted';
sessionId: string;
agentId: string;
promptId: string;
userMessageId: string;
status: string;
content: readonly PromptSubmission['content'][number][];
}
| undefined;
expect(submitted).toBeDefined();
expect(submitted?.sessionId).toBe(SID);
expect(submitted?.agentId).toBe('main');
expect(submitted).toMatchObject({
promptId: result.prompt_id,
userMessageId: result.user_message_id,
status: 'running',
content: body.content,
});
});
it('queues a second prompt when a non-terminal prompt is already active', async () => {
const { bridge } = makeBridge();
const { bus } = makeBus();
@ -360,6 +390,28 @@ describe('PromptService.submit', () => {
expect(listed.queued.map((p) => p.prompt_id)).toEqual([second.prompt_id]);
});
it('publishes prompt.submitted when a prompt is queued', async () => {
const { bridge } = makeBridge();
const { bus, events } = makeBus();
const impl = newSvc(bridge, bus);
await impl.submit(SID, mkBody({ content: [{ type: 'text', text: 'one' }] }));
const second = await impl.submit(SID, mkBody({ content: [{ type: 'text', text: 'two' }] }));
const submitted = events.filter((event) => event.type === 'prompt.submitted') as Array<{
type: 'prompt.submitted';
promptId: string;
status: string;
content: readonly PromptSubmission['content'][number][];
}>;
expect(submitted.map((event) => event.promptId)).toContain(second.prompt_id);
expect(submitted.at(-1)).toMatchObject({
promptId: second.prompt_id,
status: 'queued',
content: [{ type: 'text', text: 'two' }],
});
});
it('starts the next queued prompt after the active prompt completes', async () => {
const { bridge, record } = makeBridge();
const { bus, triggerSubscribers } = makeBus();

View file

@ -232,8 +232,27 @@ function textMessage(
let state: FakeBridgeState;
let svc: SessionService;
let promptStub: ReturnType<typeof makePromptServiceStub>;
let eventBus: ReturnType<typeof makeEventServiceStub>;
let instantiation: TestInstantiationService;
function makeEventServiceStub(): {
eventService: IEventService;
events: unknown[];
} {
const events: unknown[] = [];
const emitter = new Emitter<never>();
return {
events,
eventService: {
_serviceBrand: undefined,
publish: vi.fn((event: unknown) => {
events.push(event);
}) as IEventService['publish'],
onDidPublish: emitter.event as unknown as IEventService['onDidPublish'],
},
};
}
function makePromptServiceStub(): {
promptService: IPromptService;
calls: Array<{ sid: string; patch: Record<string, unknown>; source: string; promptId: string | undefined }>;
@ -269,9 +288,11 @@ function makeTestInstantiation(stubs: {
beforeEach(() => {
state = freshState();
promptStub = makePromptServiceStub();
eventBus = makeEventServiceStub();
instantiation = makeTestInstantiation({ promptService: promptStub.promptService });
svc = new SessionService(
makeFakeBridge(state),
eventBus.eventService,
instantiation,
);
});
@ -830,6 +851,16 @@ describe('SessionService per-domain event listeners (Phase C)', () => {
expect((events[0] as { session: { id: string } }).session.id).toBe(session.id);
});
it('publishes session.created after creating a session', async () => {
const session = await svc.create({ metadata: { cwd: '/tmp/evt-bus' } });
expect(eventBus.events).toContainEqual({
type: 'event.session.created',
sessionId: session.id,
agentId: 'main',
session,
});
});
it('onDidCreate detach stops future events', async () => {
const events: unknown[] = [];
const sub = svc.onDidCreate((e) => { events.push(e); });