@danypops/papyrus 0.27.0 → 0.27.2
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.
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* registration, its own event emission, malformed-options recovery for other tools' schemas)
|
|
11
11
|
* removed since Discuss already owns its schema, persistence, and rendering.
|
|
12
12
|
*/
|
|
13
|
-
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { AgentToolUpdateCallback, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import {
|
|
16
16
|
Container,
|
|
@@ -62,6 +62,16 @@ export interface AskQuestionParams {
|
|
|
62
62
|
allowComment?: boolean;
|
|
63
63
|
displayMode?: AskDisplayMode;
|
|
64
64
|
timeout?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Streamed once before blocking on the human. A live ask can legitimately sit pending far
|
|
67
|
+
* longer than a typical tool call (real human response time, not milliseconds) -- without any
|
|
68
|
+
* progress signal, a tool call sitting silent that long looks indistinguishable from a dead
|
|
69
|
+
* one to anything upstream watching for stalled calls. pi-ask-user's own original code (the
|
|
70
|
+
* prior art this view is adapted from) sent exactly this same heartbeat before presenting its
|
|
71
|
+
* UI; dropping it during the port was the regression that let two independent executions of
|
|
72
|
+
* the same live ask run concurrently, each opening its own picker for the same question.
|
|
73
|
+
*/
|
|
74
|
+
onUpdate?: AgentToolUpdateCallback;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
export interface AskAnswer {
|
|
@@ -1063,6 +1073,24 @@ async function askViaDialogs(
|
|
|
1063
1073
|
return createSelectionResponse([selected], comment);
|
|
1064
1074
|
}
|
|
1065
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* Tracks whether a live ask is genuinely mid-flight, blocked on the human. `ExtensionContext.isIdle()`
|
|
1078
|
+
* means "not streaming a model response" -- it reads true while a slow, human-blocking tool call
|
|
1079
|
+
* like this one is still pending, since the model already finished emitting the tool_call and
|
|
1080
|
+
* is not itself generating anything. Left unguarded, that lets the active-task continuation
|
|
1081
|
+
* driver (extension/src/index.ts's driveActiveTasks, on agent_settled) queue a "continue the
|
|
1082
|
+
* active task" nudge as a `deliverAs: "nextTurn"` message while this exact live ask is still
|
|
1083
|
+
* awaiting an answer -- starting a second, concurrent turn that reasons about the very Discussion
|
|
1084
|
+
* this call is already resolving, independently of it. A live-observed bug (two pickers for the
|
|
1085
|
+
* same question, one orphaned and later auto-resolving with fabricated "defer" text) traced back
|
|
1086
|
+
* to exactly this race. driveActiveTasks checks isLiveAskPending() and skips queuing while true.
|
|
1087
|
+
*/
|
|
1088
|
+
let livePendingCount = 0;
|
|
1089
|
+
|
|
1090
|
+
export function isLiveAskPending(): boolean {
|
|
1091
|
+
return livePendingCount > 0;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1066
1094
|
/**
|
|
1067
1095
|
* Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
|
|
1068
1096
|
* dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
|
|
@@ -1081,6 +1109,25 @@ export async function askQuestion(ctx: ExtensionContext, params: AskQuestionPara
|
|
|
1081
1109
|
const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
|
|
1082
1110
|
const normalizedContext = params.context?.trim() || undefined;
|
|
1083
1111
|
|
|
1112
|
+
params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
|
|
1113
|
+
livePendingCount += 1;
|
|
1114
|
+
try {
|
|
1115
|
+
return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
|
|
1116
|
+
} finally {
|
|
1117
|
+
livePendingCount -= 1;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function askQuestionBlocking(
|
|
1122
|
+
ctx: ExtensionContext,
|
|
1123
|
+
params: AskQuestionParams,
|
|
1124
|
+
options: AskOption[],
|
|
1125
|
+
allowMultiple: boolean,
|
|
1126
|
+
allowFreeform: boolean,
|
|
1127
|
+
allowComment: boolean,
|
|
1128
|
+
displayMode: AskDisplayMode,
|
|
1129
|
+
normalizedContext: string | undefined,
|
|
1130
|
+
): Promise<AskAnswer | undefined> {
|
|
1084
1131
|
if (options.length === 0) {
|
|
1085
1132
|
const prompt = normalizedContext ? `${params.question}\n\nContext:\n${normalizedContext}` : params.question;
|
|
1086
1133
|
const answer = await ctx.ui.input(prompt, "Type your answer...", params.timeout ? { timeout: params.timeout } : undefined);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
4
|
import { PROOF_TYPES } from "../../src/domain/checklist.ts";
|
|
@@ -39,7 +39,7 @@ function text(message: string, details: unknown = {}) {
|
|
|
39
39
|
* is available, never throws -- an unanswered live prompt still leaves the round it already
|
|
40
40
|
* recorded intact.
|
|
41
41
|
*/
|
|
42
|
-
async function liveAnswer(ctx: ExtensionContext, discussion: Artifact): Promise<{ content: string; selected?: string[] } | undefined> {
|
|
42
|
+
async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, onUpdate: AgentToolUpdateCallback | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
|
|
43
43
|
if (!ctx.hasUI) return undefined;
|
|
44
44
|
const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
|
|
45
45
|
const question = `Reply to "${discussion.title}":`;
|
|
@@ -48,9 +48,10 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact): Promise<
|
|
|
48
48
|
question,
|
|
49
49
|
options: pending.pendingOptions.map((title) => ({ title })),
|
|
50
50
|
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
51
|
+
onUpdate,
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
|
-
return askQuestion(ctx, { question });
|
|
54
|
+
return askQuestion(ctx, { question, onUpdate });
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
@@ -697,9 +698,13 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
697
698
|
selected: Type.Optional(Type.Array(Type.String())),
|
|
698
699
|
live: Type.Optional(Type.Boolean()),
|
|
699
700
|
}),
|
|
701
|
+
// Blocks other tool calls in the same assistant turn until live:true's human answer comes
|
|
702
|
+
// back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
|
|
703
|
+
// bash/edit/write and let those run before the human sees the prompt.
|
|
704
|
+
executionMode: "sequential",
|
|
700
705
|
renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
|
|
701
706
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
702
|
-
async execute(_id, rawParams, _signal,
|
|
707
|
+
async execute(_id, rawParams, _signal, onUpdate, ctx) {
|
|
703
708
|
try {
|
|
704
709
|
const params: Record<string, unknown> = { ...rawParams };
|
|
705
710
|
const action = params.action;
|
|
@@ -716,7 +721,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
716
721
|
? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
|
|
717
722
|
: text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
|
|
718
723
|
if (params.live !== true) return fallback;
|
|
719
|
-
const answer = await liveAnswer(ctx, result.discussion);
|
|
724
|
+
const answer = await liveAnswer(ctx, result.discussion, onUpdate);
|
|
720
725
|
if (!answer) return fallback;
|
|
721
726
|
const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
|
|
722
727
|
id: result.discussion.id, actor: "human", content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: "discuss-live",
|
package/extension/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { GateResult } from "../../src/domain/gate.ts";
|
|
|
22
22
|
import { formatMetadata } from "./artifact-format.ts";
|
|
23
23
|
import { callService } from "./service-client.ts";
|
|
24
24
|
import { registerDomainTools } from "./domain-tools.ts";
|
|
25
|
+
import { isLiveAskPending } from "./discuss-ask-view.ts";
|
|
25
26
|
import { registerPlaybookBridge } from "./playbook-bridge.ts";
|
|
26
27
|
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
27
28
|
import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
|
|
@@ -202,6 +203,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
202
203
|
|
|
203
204
|
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
204
205
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
206
|
+
// ctx.isIdle() means "not streaming a model response" -- it reads true while a live discuss
|
|
207
|
+
// ask is still genuinely pending, blocked on the human. Queuing a "continue the active task"
|
|
208
|
+
// nudge here would start a second, concurrent turn reasoning about the very Discussion this
|
|
209
|
+
// live ask is already resolving. See discuss-ask-view.ts's isLiveAskPending() doc comment.
|
|
210
|
+
if (isLiveAskPending()) return;
|
|
205
211
|
try {
|
|
206
212
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
207
213
|
const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd, session_id: sessionId });
|
package/package.json
CHANGED