pi-onlyne 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/README.zh.md +19 -5
- package/package.json +1 -1
- package/src/activity.mjs +294 -0
- package/src/activity.test.mjs +69 -1
- package/src/agent.mjs +139 -14
- package/src/agent.test.mjs +83 -1
- package/src/config.mjs +179 -4
- package/src/config.test.mjs +59 -1
- package/src/index.ts +177 -20
- package/src/pi-surface.mjs +80 -10
- package/src/pi-surface.test.mjs +102 -4
- package/src/resident-claim.test.mjs +461 -0
- package/src/session-scope.mjs +53 -0
- package/src/session-scope.test.mjs +34 -0
- package/src/socket.mjs +104 -4
- package/src/socket.test.mjs +157 -3
package/src/index.ts
CHANGED
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
// a plain pi session and the extension stays silent rather than failing.
|
|
9
9
|
//
|
|
10
10
|
// session_start -> bind the env session id, read env + .pi/onlyne.json,
|
|
11
|
-
// connect, register tools
|
|
11
|
+
// connect, register tools; a resident plugin that is
|
|
12
|
+
// already connected only follows the switch
|
|
13
|
+
// session_switch -> /new, /resume and /fork on a host that names them;
|
|
14
|
+
// session_branch the same re-point a resident plugin does when current
|
|
15
|
+
// session_tree pi reports the switch through session_start, and
|
|
16
|
+
// after tree navigation
|
|
12
17
|
// before_agent_start -> the role prose becomes one section of the system
|
|
13
18
|
// prompt the run is about to send (the instruction layer)
|
|
14
19
|
// turn_start -> heartbeat{running}
|
|
@@ -35,15 +40,33 @@
|
|
|
35
40
|
// environment, and a sidechain that called `onlyne_complete` would settle the
|
|
36
41
|
// parent's task. `session-scope.mjs` carries the rule, the guard behind it, and
|
|
37
42
|
// what a call from a foreign session is told.
|
|
38
|
-
|
|
43
|
+
//
|
|
44
|
+
// One process also holds at most one resident claim. A resident pi is started
|
|
45
|
+
// by hand rather than spawned by the client, so a `task` subagent's sidechain
|
|
46
|
+
// inherits nothing that names a task and reaches the resident branch through a
|
|
47
|
+
// second call of this factory with fresh closure state; the claim is what stops
|
|
48
|
+
// it from opening a connection that impersonates the parent's resident alias
|
|
49
|
+
// (`docs/v2.11-LIFECYCLE-IS-DECLARED-NOT-INFERRED.md` §4.2). Where the host
|
|
50
|
+
// itself declares what the session is — omp marks every subagent context
|
|
51
|
+
// `agent.kind === "sub"` — that declaration decides first, on both identity
|
|
52
|
+
// paths, and the claim is the reading left for a host that declares nothing.
|
|
53
|
+
//
|
|
39
54
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
40
55
|
import { Type } from "typebox";
|
|
41
56
|
|
|
42
57
|
import { OnlyneAgent } from "./agent.mjs";
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
58
|
+
import { ASSIGN_MESSAGE_TYPE, createAssignMessageComponent } from "./activity.mjs";
|
|
59
|
+
import { loadConfig, residentIdentity, sessionIdentity } from "./config.mjs";
|
|
60
|
+
import { resolveResidentSocketPath, resolveSocketPath } from "./socket.mjs";
|
|
45
61
|
import { createSurface } from "./pi-surface.mjs";
|
|
46
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
declaredSubagent,
|
|
64
|
+
foreignSessionRefusal,
|
|
65
|
+
isSessionReplacement,
|
|
66
|
+
mountsSession,
|
|
67
|
+
ownsSession,
|
|
68
|
+
sessionIdOf,
|
|
69
|
+
} from "./session-scope.mjs";
|
|
47
70
|
|
|
48
71
|
/**
|
|
49
72
|
* Node's process globals, declared here so the package needs no `@types/node`
|
|
@@ -82,11 +105,11 @@ interface PiSurface {
|
|
|
82
105
|
registerTool: boolean;
|
|
83
106
|
registerCommand: boolean;
|
|
84
107
|
};
|
|
85
|
-
wakeUser(text: string, parts?: ImagePartInput[]): boolean;
|
|
108
|
+
wakeUser(text: string, parts?: ImagePartInput[], meta?: unknown): boolean;
|
|
86
109
|
roleProse(text: string): boolean;
|
|
87
|
-
applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }): boolean;
|
|
110
|
+
applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }, fallback?: string): boolean;
|
|
88
111
|
customEntry(customType: string, data: unknown): boolean;
|
|
89
|
-
widget(lines: string[] | undefined): void;
|
|
112
|
+
widget(lines: string[] | undefined, activity?: unknown): void;
|
|
90
113
|
status(text: string): void;
|
|
91
114
|
welcome(welcome: WelcomeLike): void;
|
|
92
115
|
isIdle(): boolean;
|
|
@@ -140,6 +163,18 @@ const textResult = (text: string, details?: unknown) => ({
|
|
|
140
163
|
details,
|
|
141
164
|
});
|
|
142
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The session this process has already taken as its resident, or `null` while
|
|
168
|
+
* no resident mount holds it.
|
|
169
|
+
*
|
|
170
|
+
* Module scope is the point: pi hosts a `task` subagent's sidechain in the same
|
|
171
|
+
* process by loading this extension again, so a guard that read the factory's
|
|
172
|
+
* own `boundSessionId` compared `null` against a name and let the child open a
|
|
173
|
+
* connection for the parent's resident alias
|
|
174
|
+
* (`docs/v2.11-LIFECYCLE-IS-DECLARED-NOT-INFERRED.md` §4.2).
|
|
175
|
+
*/
|
|
176
|
+
let residentClaim: string | null = null;
|
|
177
|
+
|
|
143
178
|
export default function onlyne(pi: ExtensionAPI) {
|
|
144
179
|
const env: Record<string, string | undefined> = typeof process === "undefined" ? {} : process.env;
|
|
145
180
|
let context: ExtensionContext | null = null;
|
|
@@ -173,6 +208,16 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
173
208
|
const registerSurface = () => {
|
|
174
209
|
if (registered || !surface) return;
|
|
175
210
|
registered = true;
|
|
211
|
+
if ("registerMessageRenderer" in pi && typeof pi.registerMessageRenderer === "function") {
|
|
212
|
+
try {
|
|
213
|
+
pi.registerMessageRenderer(ASSIGN_MESSAGE_TYPE, (message, options, theme) => {
|
|
214
|
+
if (!message?.details || typeof message.details !== "object") return undefined;
|
|
215
|
+
return createAssignMessageComponent(message.details, message.content, theme, !options?.expanded);
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
log(`registerMessageRenderer(${ASSIGN_MESSAGE_TYPE}) refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
176
221
|
try {
|
|
177
222
|
pi.registerTool(defineTool({
|
|
178
223
|
name: "onlyne_send",
|
|
@@ -329,9 +374,68 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
329
374
|
}
|
|
330
375
|
};
|
|
331
376
|
|
|
332
|
-
|
|
333
|
-
|
|
377
|
+
/**
|
|
378
|
+
* Re-point the plugin at the session pi made current. The socket belongs to
|
|
379
|
+
* the role, not to the session file that mounted it: /new, /resume, /fork and
|
|
380
|
+
* a tree jump replace the live session in this process, and the tools must
|
|
381
|
+
* follow it instead of refusing every call as foreign.
|
|
382
|
+
*/
|
|
383
|
+
const syncSessionContext = (_event: unknown, ctx: ExtensionContext) => {
|
|
384
|
+
if (!agent) return;
|
|
385
|
+
// The same declaration the mount gate reads, on the other door: a context
|
|
386
|
+
// the host declares a subagent must not re-point this plugin's connection
|
|
387
|
+
// onto a child, whatever session id it carries.
|
|
388
|
+
if (declaredSubagent(ctx)) return;
|
|
389
|
+
context = ctx;
|
|
390
|
+
const currentId = sessionIdOf(ctx?.sessionManager);
|
|
391
|
+
if (currentId) {
|
|
392
|
+
// `/new`, `/resume` and a tree jump replace the live session inside this
|
|
393
|
+
// process rather than starting a second resident, so the claim moves with
|
|
394
|
+
// it — but only the mount that holds the claim may move it. A factory that
|
|
395
|
+
// never mounted returned above, and one whose mount is not the claim
|
|
396
|
+
// holder leaves the claim where it is. The comparison reads the session
|
|
397
|
+
// this mount held until now, so it goes before the rebind.
|
|
398
|
+
if (boundSessionId === residentClaim) residentClaim = currentId;
|
|
399
|
+
boundSessionId = currentId;
|
|
400
|
+
}
|
|
401
|
+
if (surface?.available.widget) {
|
|
402
|
+
surface.widget(agent.activity.lines(), agent.activity);
|
|
403
|
+
}
|
|
404
|
+
surface?.status(agent.activity.lines()[0]);
|
|
405
|
+
// A surface that was replaced starts with no prose of its own, and the run
|
|
406
|
+
// after a switch still owes the model the role spec: the agent remembers
|
|
407
|
+
// what the last welcome carried and hands it over here.
|
|
408
|
+
agent.syncRoleProse();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
pi.on("session_start", async (event, ctx) => {
|
|
412
|
+
const spawned = sessionIdentity(env);
|
|
413
|
+
const resident = spawned ? null : residentIdentity(env, ctx.cwd);
|
|
414
|
+
const identity = spawned ?? resident;
|
|
334
415
|
if (!identity) return; // not an onlyne session: stay out of the way
|
|
416
|
+
const starting = sessionIdOf(ctx.sessionManager);
|
|
417
|
+
// The host's own declaration comes first, before either identity path: a
|
|
418
|
+
// session it declares a subagent — a `task` subagent, an `eval` child, a
|
|
419
|
+
// `/tan` clone, all re-bound through this factory in this process — is never
|
|
420
|
+
// the session a role's tools belong to, even when it inherits the client's
|
|
421
|
+
// environment or arrives with no session id at all
|
|
422
|
+
// (docs/v2.11-LIFECYCLE-IS-DECLARED-NOT-INFERRED.md §2 rule 2).
|
|
423
|
+
const subagent = declaredSubagent(ctx);
|
|
424
|
+
if (subagent) {
|
|
425
|
+
log(
|
|
426
|
+
`session ${starting} is subagent ${subagent.id ?? "?"} of ${subagent.parentId ?? "?"}; staying inert`
|
|
427
|
+
);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// A resident plugin already holds its connection, so another session_start
|
|
431
|
+
// in this process is a re-point, not a mount: /new, /resume and /fork
|
|
432
|
+
// replace the live session, and pi can re-bind this same session's
|
|
433
|
+
// extensions. A subagent on a host that declares no agent identity re-binds
|
|
434
|
+
// with reason "startup" and a different id, and is refused below.
|
|
435
|
+
if (!spawned && agent !== null && (event.reason !== "startup" || starting === boundSessionId)) {
|
|
436
|
+
syncSessionContext(event, ctx);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
335
439
|
// Only the session the client names may mount. A pi process runs more than
|
|
336
440
|
// one session — a `task` subagent's sidechain, `/new` — and each of them
|
|
337
441
|
// loads this extension with the same inherited environment, so without this
|
|
@@ -339,13 +443,34 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
339
443
|
// librarian task cfa73386). An unknown id means an older pi that does not
|
|
340
444
|
// expose one; the env-bound mount is then the only candidate and stays the
|
|
341
445
|
// behaviour, with no line to explain.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
446
|
+
if (spawned) {
|
|
447
|
+
if (!mountsSession(spawned.sessionId, starting)) {
|
|
448
|
+
log(`session ${starting} is not the onlyne session ${spawned.sessionId}; staying inert`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
// The claim is process-wide, so it holds across factory calls: a `task`
|
|
453
|
+
// subagent's sidechain loads this extension again with fresh closure state
|
|
454
|
+
// and would otherwise mount the alias this process already serves. A
|
|
455
|
+
// `session_start` the host calls a replacement is the other reading: the
|
|
456
|
+
// live session is moving inside this process, so the claim moves with it
|
|
457
|
+
// instead of refusing the session that is now the resident. An older pi
|
|
458
|
+
// that names no session id is the one case with nothing to compare — it
|
|
459
|
+
// mounts as it did before, and claims nothing.
|
|
460
|
+
if (
|
|
461
|
+
residentClaim !== null &&
|
|
462
|
+
starting !== null &&
|
|
463
|
+
starting !== residentClaim &&
|
|
464
|
+
!isSessionReplacement(event)
|
|
465
|
+
) {
|
|
466
|
+
log(`session ${starting} is not the resident session ${residentClaim}; staying inert`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (starting !== null) residentClaim = starting;
|
|
346
470
|
}
|
|
471
|
+
const wireSessionId = identity.sessionId ?? starting;
|
|
347
472
|
context = ctx;
|
|
348
|
-
boundSessionId =
|
|
473
|
+
boundSessionId = spawned ? spawned.sessionId : (starting ?? wireSessionId);
|
|
349
474
|
const config = loadConfig(ctx.cwd);
|
|
350
475
|
if (config.warning) log(config.warning);
|
|
351
476
|
if (!config.enabled) {
|
|
@@ -357,23 +482,36 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
357
482
|
// neither has no socket to dial, and saying so is the whole answer: this
|
|
358
483
|
// stays a plain pi session instead of retrying a path nothing serves.
|
|
359
484
|
let socketPath: string | null = null;
|
|
485
|
+
const targetWorkspace = resident ? resident.workspaceRoot : ctx.cwd;
|
|
486
|
+
const declaredCluster = resident ? resident.cluster : null;
|
|
360
487
|
try {
|
|
361
|
-
socketPath =
|
|
488
|
+
socketPath = resident
|
|
489
|
+
? resolveResidentSocketPath(env, targetWorkspace, identity.role, { cluster: declaredCluster })
|
|
490
|
+
: resolveSocketPath(env, ctx.cwd);
|
|
362
491
|
} catch (error) {
|
|
363
492
|
log(`socket unresolved: ${error instanceof Error ? error.message : String(error)}`);
|
|
364
493
|
}
|
|
365
494
|
if (socketPath === null) return;
|
|
366
|
-
|
|
495
|
+
// The prose a mount of this process already holds: the agent is replaced
|
|
496
|
+
// below, and the surface made now starts empty, so what the last welcome
|
|
497
|
+
// carried moves with it rather than being lost until the next handshake.
|
|
498
|
+
const carriedProse = agent?.latestProse ?? "";
|
|
499
|
+
surface = createSurface({ pi, log, context: () => context, sessionId: () => boundSessionId });
|
|
367
500
|
agent = new OnlyneAgent({
|
|
368
501
|
socketPath,
|
|
502
|
+
resolveSocket: resident
|
|
503
|
+
? () => resolveResidentSocketPath(env, targetWorkspace, identity.role, { cluster: declaredCluster })
|
|
504
|
+
: undefined,
|
|
369
505
|
cwd: ctx.cwd,
|
|
370
506
|
role: identity.role,
|
|
371
|
-
sessionId:
|
|
507
|
+
sessionId: wireSessionId,
|
|
372
508
|
taskId: identity.taskId,
|
|
509
|
+
resident: Boolean(resident),
|
|
373
510
|
surface,
|
|
374
511
|
log,
|
|
512
|
+
prose: carriedProse,
|
|
375
513
|
});
|
|
376
|
-
log(`session ${
|
|
514
|
+
log(`session ${wireSessionId ?? "resident"} role=${identity.role} socket=${socketPath}`);
|
|
377
515
|
registerSurface();
|
|
378
516
|
if (!surface.available.wakeUser) {
|
|
379
517
|
// Without an injection surface the payload travels through config_get,
|
|
@@ -384,13 +522,28 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
384
522
|
if (config.autoStart) agent.start();
|
|
385
523
|
});
|
|
386
524
|
|
|
525
|
+
// `session_switch` and `session_branch` name the same transitions in the omp
|
|
526
|
+
// lineage of this API; current pi reports them as `session_start` with a
|
|
527
|
+
// reason and keeps `session_tree` for navigation, so one helper serves all.
|
|
528
|
+
// The installed typings no longer declare the first two, but the host stores
|
|
529
|
+
// handlers by name, which this narrow cast is the whole boundary of.
|
|
530
|
+
const legacySessionEvents = pi as unknown as {
|
|
531
|
+
on(event: "session_switch" | "session_branch", handler: (event: unknown, ctx: ExtensionContext) => void): void;
|
|
532
|
+
};
|
|
533
|
+
legacySessionEvents.on("session_switch", syncSessionContext);
|
|
534
|
+
legacySessionEvents.on("session_branch", syncSessionContext);
|
|
535
|
+
pi.on("session_tree", async (event, ctx) => syncSessionContext(event, ctx));
|
|
536
|
+
|
|
387
537
|
// The role prose is instruction-layer text: `before_agent_start` hands the
|
|
388
538
|
// handler the prompt options the run is about to render, and a section written
|
|
389
539
|
// there is part of the system prompt rather than one more message the model has
|
|
390
540
|
// to read as an utterance. Outside an onlyne session there is no surface and
|
|
391
541
|
// nothing to add.
|
|
392
542
|
pi.on("before_agent_start", async (event) => {
|
|
393
|
-
surface
|
|
543
|
+
// A surface created after the handshake holds no prose; the agent's copy of
|
|
544
|
+
// the last welcome is what the run that is starting carries instead of
|
|
545
|
+
// running without the role spec until the next connection.
|
|
546
|
+
surface?.applyRoleProse(event, agent?.latestProse ?? "");
|
|
394
547
|
});
|
|
395
548
|
|
|
396
549
|
pi.on("turn_start", async () => {
|
|
@@ -420,6 +573,10 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
420
573
|
agent?.stop(`pi:${event.reason ?? "quit"}`);
|
|
421
574
|
surface?.closeBackground?.();
|
|
422
575
|
surface?.widget?.(undefined);
|
|
576
|
+
// Only the mount holding the claim may release it: a child factory that
|
|
577
|
+
// never mounted has no `boundSessionId` of its own, and its shutdown must
|
|
578
|
+
// leave the resident's claim where it is.
|
|
579
|
+
if (boundSessionId !== null && boundSessionId === residentClaim) residentClaim = null;
|
|
423
580
|
agent = null;
|
|
424
581
|
surface = null;
|
|
425
582
|
registered = false;
|
package/src/pi-surface.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// subagentRegistry ~/.pi/subagents/missions (background-subagents.mjs)
|
|
19
19
|
// registerTool / registerCommand are probed by index.ts itself.
|
|
20
20
|
|
|
21
|
-
import { WIDGET_KEY } from "./activity.mjs";
|
|
21
|
+
import { ASSIGN_MESSAGE_TYPE, WIDGET_KEY } from "./activity.mjs";
|
|
22
22
|
import { createSubagentProbe } from "./background-subagents.mjs";
|
|
23
23
|
import { createBackgroundProbe } from "./background-work.mjs";
|
|
24
24
|
|
|
@@ -34,6 +34,7 @@ export const PROSE_SECTION = "onlyne-role-prose";
|
|
|
34
34
|
* @param {{ pi: any, log: (line: string) => void, context: () => any, sessionId?: string | null }} options
|
|
35
35
|
*/
|
|
36
36
|
export function createSurface({ pi, log, context, sessionId = null }) {
|
|
37
|
+
let currentSessionId = typeof sessionId === "function" ? sessionId : () => sessionId;
|
|
37
38
|
const has = (value) => typeof value === "function";
|
|
38
39
|
const ctx = () => {
|
|
39
40
|
try {
|
|
@@ -71,7 +72,7 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
71
72
|
// leave a subagent running with no tool event to bracket it. Scoped to this
|
|
72
73
|
// session's own missions when the plugin knows its id.
|
|
73
74
|
const subagents = createSubagentProbe({
|
|
74
|
-
getSessionId: () =>
|
|
75
|
+
getSessionId: () => currentSessionId(),
|
|
75
76
|
log,
|
|
76
77
|
});
|
|
77
78
|
|
|
@@ -95,7 +96,9 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
95
96
|
const available = {
|
|
96
97
|
wakeUser: has(pi.sendUserMessage),
|
|
97
98
|
customEntry: has(pi.appendEntry),
|
|
98
|
-
widget
|
|
99
|
+
get widget() {
|
|
100
|
+
return has(ctx()?.ui?.setWidget);
|
|
101
|
+
},
|
|
99
102
|
status: true,
|
|
100
103
|
exit: true,
|
|
101
104
|
isIdle: true,
|
|
@@ -103,9 +106,19 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
103
106
|
registerCommand: has(pi.registerCommand),
|
|
104
107
|
};
|
|
105
108
|
|
|
106
|
-
const widget = (lines) => {
|
|
109
|
+
const widget = (lines, activity = null) => {
|
|
107
110
|
try {
|
|
108
|
-
ctx()?.ui
|
|
111
|
+
const ui = ctx()?.ui;
|
|
112
|
+
if (!has(ui?.setWidget)) return;
|
|
113
|
+
if (lines === undefined) {
|
|
114
|
+
ui.setWidget(WIDGET_KEY, undefined);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (activity && typeof activity.component === "function") {
|
|
118
|
+
ui.setWidget(WIDGET_KEY, (tui, theme) => activity.component(theme, tui));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
ui.setWidget(WIDGET_KEY, lines);
|
|
109
122
|
} catch {
|
|
110
123
|
/* widget is decoration; never let it break the protocol */
|
|
111
124
|
}
|
|
@@ -120,7 +133,7 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
120
133
|
};
|
|
121
134
|
|
|
122
135
|
/**
|
|
123
|
-
* One
|
|
136
|
+
* One delivery, one message; images ride along as pi image content parts.
|
|
124
137
|
*
|
|
125
138
|
* pi reads an image part as the flat `ImageContent` of its message types —
|
|
126
139
|
* `data` plus `mimeType` — and normalizes every part before the message is
|
|
@@ -128,8 +141,21 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
128
141
|
* pi reports that failure in its own pane and hands nothing back to this
|
|
129
142
|
* plugin, so an unusable part is dropped here and the assignment still
|
|
130
143
|
* travels: the delivery text already names the path the client wrote.
|
|
144
|
+
*
|
|
145
|
+
* With `meta` and `pi.sendMessage`, the `onlyne_assign` card IS the delivery:
|
|
146
|
+
* a custom message reaches the model, and it is what the transcript draws, so
|
|
147
|
+
* a delivery does not also arrive as a second user message. `triggerTurn` is
|
|
148
|
+
* what an idle session owes it (a `display: true` custom message renders
|
|
149
|
+
* without starting a turn of its own); while a turn is running the card queues
|
|
150
|
+
* behind it as a follow-up instead of interrupting tool execution.
|
|
151
|
+
*
|
|
152
|
+
* A pi without `sendMessage` takes the plain `sendUserMessage` below — idle
|
|
153
|
+
* without `deliverAs` so the delivery starts the turn it owes, `followUp` when
|
|
154
|
+
* busy (`deliverAs: "followUp"` only enqueues and requires a prior
|
|
155
|
+
* assistant/toolResult tail to auto-continue). That path is the pre-card
|
|
156
|
+
* shape, kept so an older pi still receives a delivery.
|
|
131
157
|
*/
|
|
132
|
-
const wakeUser = (text, parts = []) => {
|
|
158
|
+
const wakeUser = (text, parts = [], meta = null) => {
|
|
133
159
|
if (!available.wakeUser) {
|
|
134
160
|
log("pi has no sendUserMessage; the assignment reached the session log only");
|
|
135
161
|
return false;
|
|
@@ -150,8 +176,43 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
150
176
|
mimeType: part.mime,
|
|
151
177
|
})),
|
|
152
178
|
];
|
|
179
|
+
const current = ctx();
|
|
180
|
+
let idle = false;
|
|
181
|
+
try {
|
|
182
|
+
if (has(current?.isIdle)) idle = current.isIdle() === true;
|
|
183
|
+
} catch {
|
|
184
|
+
idle = false;
|
|
185
|
+
}
|
|
186
|
+
if (meta && typeof meta === "object" && has(pi.sendMessage)) {
|
|
187
|
+
try {
|
|
188
|
+
pi.sendMessage(
|
|
189
|
+
{
|
|
190
|
+
customType: ASSIGN_MESSAGE_TYPE,
|
|
191
|
+
content,
|
|
192
|
+
display: true,
|
|
193
|
+
details: {
|
|
194
|
+
role: meta.role ?? null,
|
|
195
|
+
taskId: meta.taskId ?? null,
|
|
196
|
+
kind: meta.kind ?? "task",
|
|
197
|
+
from: meta.from ?? null,
|
|
198
|
+
envelopeId: meta.envelopeId ?? null,
|
|
199
|
+
bodyText: meta.bodyText ?? text,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
idle ? { triggerTurn: true } : { deliverAs: "followUp" },
|
|
203
|
+
);
|
|
204
|
+
return true;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
log(`sendMessage refused: ${message}; the delivery takes the user-message route`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
153
210
|
try {
|
|
154
|
-
|
|
211
|
+
if (idle) {
|
|
212
|
+
pi.sendUserMessage(content);
|
|
213
|
+
} else {
|
|
214
|
+
pi.sendUserMessage(content, { deliverAs: "followUp" });
|
|
215
|
+
}
|
|
155
216
|
return true;
|
|
156
217
|
} catch (error) {
|
|
157
218
|
try {
|
|
@@ -180,7 +241,9 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
180
241
|
let warnedNoSections = false;
|
|
181
242
|
|
|
182
243
|
/**
|
|
183
|
-
*
|
|
244
|
+
* Take the role prose this surface is to carry: handed over with the
|
|
245
|
+
* handshake, and again whenever a surface that holds none has to be filled
|
|
246
|
+
* for the run that is starting.
|
|
184
247
|
* @param {string} text
|
|
185
248
|
* @returns {boolean} whether there is prose to carry
|
|
186
249
|
*/
|
|
@@ -198,10 +261,17 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
198
261
|
* pi's own examples does this). pi rebuilds those options for every run, so the
|
|
199
262
|
* section is written again rather than once — the write is idempotent, and the
|
|
200
263
|
* first run records it in the transcript's system message.
|
|
264
|
+
*
|
|
265
|
+
* `fallback` is the prose this surface was never handed: a surface made after
|
|
266
|
+
* the handshake (a session switch re-binds the plugin onto one) starts empty,
|
|
267
|
+
* and the run that is starting still owes the model the role spec, so the
|
|
268
|
+
* caller passes what the agent remembers of the last welcome.
|
|
201
269
|
* @param {{ systemPromptOptions?: { sections?: Record<string, string> } } | null} event
|
|
270
|
+
* @param {string} [fallback]
|
|
202
271
|
* @returns {boolean} whether a section was written
|
|
203
272
|
*/
|
|
204
|
-
const applyRoleProse = (event) => {
|
|
273
|
+
const applyRoleProse = (event, fallback = "") => {
|
|
274
|
+
if (roleProseText.length === 0 && typeof fallback === "string") roleProseText = fallback;
|
|
205
275
|
if (roleProseText.length === 0) return false;
|
|
206
276
|
const sections = event?.systemPromptOptions?.sections;
|
|
207
277
|
if (!sections) {
|
package/src/pi-surface.test.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
17
|
import { test } from "node:test";
|
|
18
18
|
|
|
19
|
+
import { ASSIGN_MESSAGE_TYPE } from "./activity.mjs";
|
|
19
20
|
import { PROSE_SECTION, createSurface } from "./pi-surface.mjs";
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -40,7 +41,7 @@ const PNG_CONTENT = [
|
|
|
40
41
|
* know an option does.
|
|
41
42
|
* @param {{ refuse?: (content: unknown, options: unknown) => boolean }} [options]
|
|
42
43
|
*/
|
|
43
|
-
function fakeSurface({ refuse = null } = {}) {
|
|
44
|
+
function fakeSurface({ refuse = null, ctx = null } = {}) {
|
|
44
45
|
const calls = [];
|
|
45
46
|
const messages = [];
|
|
46
47
|
const lines = [];
|
|
@@ -50,14 +51,12 @@ function fakeSurface({ refuse = null } = {}) {
|
|
|
50
51
|
calls.push({ content, options });
|
|
51
52
|
if (refuse?.(content, options)) throw new Error("this pi does not take that option");
|
|
52
53
|
},
|
|
53
|
-
// Recorded, never expected: no part of this plugin is a conversation
|
|
54
|
-
// message any more, and the prose is the one that could creep back.
|
|
55
54
|
sendMessage(message, options) {
|
|
56
55
|
messages.push({ message, options });
|
|
57
56
|
},
|
|
58
57
|
},
|
|
59
58
|
log: (line) => lines.push(line),
|
|
60
|
-
context: () =>
|
|
59
|
+
context: () => ctx,
|
|
61
60
|
});
|
|
62
61
|
return { calls, messages, lines, surface };
|
|
63
62
|
}
|
|
@@ -128,6 +127,81 @@ test("delivery asks for followUp and a refusal of that option retries with the s
|
|
|
128
127
|
assert.deepEqual(refusing.lines, [], "a delivery that landed on the retry is not a failure");
|
|
129
128
|
});
|
|
130
129
|
|
|
130
|
+
test("a card delivery is one message: it starts the idle turn and queues while busy", () => {
|
|
131
|
+
// One delivery, one message. omp's rule for a custom message decides the
|
|
132
|
+
// option: with no `deliverAs` a `display: true` message renders without
|
|
133
|
+
// starting a turn, `triggerTurn` starts one when the session is idle, and
|
|
134
|
+
// `deliverAs: "followUp"` only queues behind a running turn.
|
|
135
|
+
const turns = [];
|
|
136
|
+
const queued = [];
|
|
137
|
+
const userMessages = [];
|
|
138
|
+
const cards = [];
|
|
139
|
+
let idle = true;
|
|
140
|
+
const surface = createSurface({
|
|
141
|
+
pi: {
|
|
142
|
+
sendMessage(message, options) {
|
|
143
|
+
cards.push({ message, options });
|
|
144
|
+
if (options?.triggerTurn === true && idle) {
|
|
145
|
+
turns.push(message.content);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (options?.deliverAs === "followUp") queued.push(message.content);
|
|
149
|
+
},
|
|
150
|
+
sendUserMessage(content, options) {
|
|
151
|
+
userMessages.push({ content, options });
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
log: () => {},
|
|
155
|
+
context: () => ({ isIdle: () => idle }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const meta = {
|
|
159
|
+
role: "caerulea",
|
|
160
|
+
taskId: "97971b93-0000-4000-8000-000000000000",
|
|
161
|
+
kind: "note",
|
|
162
|
+
from: "role:_supervisor",
|
|
163
|
+
envelopeId: "env-1",
|
|
164
|
+
bodyText: "ping from supervisor",
|
|
165
|
+
};
|
|
166
|
+
const text = "From _supervisor:\n\nping from supervisor";
|
|
167
|
+
assert.equal(surface.wakeUser(text, [], meta), true);
|
|
168
|
+
assert.equal(cards.length, 1);
|
|
169
|
+
assert.equal(cards[0].message.customType, ASSIGN_MESSAGE_TYPE);
|
|
170
|
+
assert.equal(cards[0].message.display, true);
|
|
171
|
+
assert.equal(cards[0].message.details.kind, "note");
|
|
172
|
+
assert.equal(cards[0].message.details.from, "role:_supervisor");
|
|
173
|
+
assert.deepEqual(cards[0].options, { triggerTurn: true });
|
|
174
|
+
assert.deepEqual(turns, [text], "an idle session starts its turn from the card");
|
|
175
|
+
assert.deepEqual(userMessages, [], "the delivery is one message, never a card plus a prompt");
|
|
176
|
+
|
|
177
|
+
// While busy (`isIdle() === false`), the next card queues as followUp instead
|
|
178
|
+
// of interrupting the running turn.
|
|
179
|
+
idle = false;
|
|
180
|
+
const second = "From _supervisor:\n\nsecond note while busy";
|
|
181
|
+
assert.equal(surface.wakeUser(second, [], meta), true);
|
|
182
|
+
assert.equal(cards.length, 2);
|
|
183
|
+
assert.deepEqual(cards[1].options, { deliverAs: "followUp" });
|
|
184
|
+
assert.deepEqual(queued, [second]);
|
|
185
|
+
assert.deepEqual(turns, [text], "a busy session queues rather than starting a second turn");
|
|
186
|
+
assert.deepEqual(userMessages, []);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a pi without sendMessage takes the delivery as a plain user message", () => {
|
|
190
|
+
const calls = [];
|
|
191
|
+
const surface = createSurface({
|
|
192
|
+
pi: {
|
|
193
|
+
sendUserMessage(content, options) {
|
|
194
|
+
calls.push({ content, options });
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
log: () => {},
|
|
198
|
+
context: () => ({ isIdle: () => true }),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
assert.equal(surface.wakeUser(TASK_TEXT, [], { kind: "note" }), true);
|
|
202
|
+
assert.deepEqual(calls, [{ content: TASK_TEXT, options: undefined }]);
|
|
203
|
+
});
|
|
204
|
+
|
|
131
205
|
/** One role prose, in the shape the client renders and `agent.mjs` hands over. */
|
|
132
206
|
const PROSE_TEXT = "Read the incoming task.\n\nAnswer in the house style.\n";
|
|
133
207
|
|
|
@@ -186,6 +260,30 @@ test("a pi whose prompt options carry no sections reports it once and carries no
|
|
|
186
260
|
assert.match(lines[0], /no instruction layer/);
|
|
187
261
|
});
|
|
188
262
|
|
|
263
|
+
test("a surface made after the handshake adopts the prose the agent remembers", () => {
|
|
264
|
+
const { surface } = fakeSurface();
|
|
265
|
+
|
|
266
|
+
// No `roleProse` call ever reached this surface — it was created after the
|
|
267
|
+
// welcome, by a session switch or a resumed mount — and the run that is
|
|
268
|
+
// starting is still owed the role spec.
|
|
269
|
+
const first = promptEvent();
|
|
270
|
+
assert.equal(surface.applyRoleProse(first, PROSE_TEXT), true);
|
|
271
|
+
assert.deepEqual(first.systemPromptOptions.sections, { [PROSE_SECTION]: PROSE_TEXT });
|
|
272
|
+
|
|
273
|
+
// The adopted text is the surface's own from then on: the next run carries it
|
|
274
|
+
// without the caller passing it again.
|
|
275
|
+
const second = promptEvent();
|
|
276
|
+
assert.equal(surface.applyRoleProse(second), true);
|
|
277
|
+
assert.deepEqual(second.systemPromptOptions.sections, { [PROSE_SECTION]: PROSE_TEXT });
|
|
278
|
+
|
|
279
|
+
// A fallback fills an empty surface only; prose the surface already holds is
|
|
280
|
+
// newer than what the agent last saw.
|
|
281
|
+
surface.roleProse("Answer in the log style.");
|
|
282
|
+
const third = promptEvent();
|
|
283
|
+
surface.applyRoleProse(third, PROSE_TEXT);
|
|
284
|
+
assert.deepEqual(third.systemPromptOptions.sections, { [PROSE_SECTION]: "Answer in the log style." });
|
|
285
|
+
});
|
|
286
|
+
|
|
189
287
|
/**
|
|
190
288
|
* A surface over a fake pi that answers the two questions the phase is read
|
|
191
289
|
* from, plus the cases where pi answers neither.
|