| 1 | // Run: tsx src/__tests__/edit-replay.test.ts |
| 2 | |
| 3 | import { replaySubmitText, replaySubmitTextPreservingSelectedContext } from "../lib/editReplay"; |
| 4 | import { invocationSegmentsFromMessage, replaceInvocationTextRange, serializeInvocationSubmit, type ComposerInvocation } from "../lib/invocationDisplay"; |
| 5 | import { formatSelectedTextContext, formatSelectionLabel, stripSelectionLabels } from "../lib/selectedTextContext"; |
| 6 | |
| 7 | let passed = 0; |
| 8 | let failed = 0; |
| 9 | |
| 10 | function eq(a: unknown, b: unknown, label: string) { |
| 11 | if (a === b) { |
| 12 | process.stdout.write(` PASS ${label}\n`); |
| 13 | passed += 1; |
| 14 | } else { |
| 15 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 16 | failed += 1; |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | console.log("\nedit replay"); |
| 21 | |
| 22 | eq( |
| 23 | replaySubmitText("hidden session context\nvisible prompt", "visible prompt", "visible prompt", "visible prompt"), |
| 24 | "hidden session context\nvisible prompt", |
| 25 | "unchanged edits preserve the original submitted text", |
| 26 | ); |
| 27 | |
| 28 | eq( |
| 29 | replaySubmitText("hidden session context\nvisible prompt @.reasonix/attachments/a.png", "visible prompt @[a.png](.reasonix/attachments/a.png)", "updated prompt @[a.png](.reasonix/attachments/a.png)", "updated prompt @.reasonix/attachments/a.png"), |
| 30 | "hidden session context\nupdated prompt @.reasonix/attachments/a.png", |
| 31 | "edited visible text preserves submit-only prefix and raw attachment refs", |
| 32 | ); |
| 33 | |
| 34 | eq( |
| 35 | replaySubmitText(undefined, "visible prompt", "updated prompt", "updated prompt"), |
| 36 | "updated prompt", |
| 37 | "messages without hidden submit context use the rebuilt submit text", |
| 38 | ); |
| 39 | |
| 40 | const selectedReferences = [{ id: "chat-1", text: "selected assistant response" }]; |
| 41 | const selectedLabel = formatSelectionLabel(selectedReferences[0]); |
| 42 | const selectedContext = formatSelectedTextContext(selectedReferences); |
| 43 | const selectedDisplay = `visible prompt ${selectedLabel}`; |
| 44 | const selectedEditableDisplay = stripSelectionLabels(selectedDisplay, selectedReferences); |
| 45 | const replayedSelectedEdit = replaySubmitTextPreservingSelectedContext( |
| 46 | `visible prompt\n\n${selectedContext}`, |
| 47 | selectedEditableDisplay, |
| 48 | "updated prompt", |
| 49 | "updated prompt", |
| 50 | ); |
| 51 | eq( |
| 52 | replayedSelectedEdit, |
| 53 | `updated prompt\n\n${selectedContext}`, |
| 54 | "editing a selected-context message preserves the quoted context suffix without submitting its display label", |
| 55 | ); |
| 56 | |
| 57 | eq( |
| 58 | replaySubmitText("/reasonix-develop review this change", "review this change", "review the updated change", "review the updated change"), |
| 59 | "/reasonix-develop review the updated change", |
| 60 | "editing a structured skill message preserves its slash invocation", |
| 61 | ); |
| 62 | |
| 63 | const mixedInvocations: ComposerInvocation[] = [ |
| 64 | { id: "primary", offset: 0, command: { name: "general-purpose", description: "", kind: "subagent" } }, |
| 65 | { id: "skill", offset: 2, command: { name: "activity-dynamic-debug", description: "", kind: "skill" } }, |
| 66 | { id: "explore", offset: 3, command: { name: "explore", description: "", kind: "subagent" } }, |
| 67 | ]; |
| 68 | const mixedSubmit = serializeInvocationSubmit("你是再做", mixedInvocations); |
| 69 | eq( |
| 70 | mixedSubmit, |
| 71 | "/general-purpose 你是 /activity-dynamic-debug 再 /explore 做", |
| 72 | "multiple abilities serialize in visual order", |
| 73 | ); |
| 74 | const mixedSegments = invocationSegmentsFromMessage("你是再做", mixedSubmit); |
| 75 | eq( |
| 76 | mixedSegments.filter((segment) => segment.type === "invocation").map((segment) => segment.type === "invocation" ? segment.invocation.name : "").join(","), |
| 77 | "general-purpose,activity-dynamic-debug,explore", |
| 78 | "multiple abilities restore from display and submit text", |
| 79 | ); |
| 80 | eq( |
| 81 | mixedSegments.filter((segment) => segment.type === "text").map((segment) => segment.type === "text" ? segment.content : "").join(""), |
| 82 | "你是再做", |
| 83 | "restored ability segments preserve visible task text", |
| 84 | ); |
| 85 | |
| 86 | eq( |
| 87 | replaySubmitText(mixedSubmit, "你是再做", "请开发并检查", "请开发并检查"), |
| 88 | "/general-purpose 请开发 /activity-dynamic-debug 并检 /explore 查", |
| 89 | "editing a mixed ability message preserves every invocation", |
| 90 | ); |
| 91 | |
| 92 | const boundaryInvocations: ComposerInvocation[] = [ |
| 93 | { id: "first", offset: 0, command: { name: "general-purpose", description: "", kind: "subagent" } }, |
| 94 | { id: "second", offset: 0, command: { name: "explore", description: "", kind: "subagent" } }, |
| 95 | ]; |
| 96 | const insertedAfterFirst = replaceInvocationTextRange("", boundaryInvocations, 0, 0, "task", "first"); |
| 97 | eq( |
| 98 | insertedAfterFirst.invocations.map((invocation) => `${invocation.id}:${invocation.offset}`).join(","), |
| 99 | "first:0,second:4", |
| 100 | "inserting after an invocation preserves the selected entity boundary", |
| 101 | ); |
| 102 | eq( |
| 103 | serializeInvocationSubmit(insertedAfterFirst.text, insertedAfterFirst.invocations), |
| 104 | "/general-purpose task /explore", |
| 105 | "same-offset entities serialize around inserted text in visual order", |
| 106 | ); |
| 107 | eq( |
| 108 | invocationSegmentsFromMessage("develop x", "/my-formatter develop /explore x") |
| 109 | .filter((segment) => segment.type === "invocation") |
| 110 | .map((segment) => segment.type === "invocation" ? segment.invocation.name : "") |
| 111 | .join(","), |
| 112 | "my-formatter,explore", |
| 113 | "spaced English messages restore every inline invocation", |
| 114 | ); |
| 115 | |
| 116 | const sessionPrefix = "以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\n"; |
| 117 | eq( |
| 118 | replaySubmitText( |
| 119 | `${sessionPrefix}/reasonix-develop review this change`, |
| 120 | "review this change", |
| 121 | "review the updated change", |
| 122 | "review the updated change", |
| 123 | ), |
| 124 | `${sessionPrefix}/reasonix-develop review the updated change`, |
| 125 | "editing a structured message keeps the hidden referenced-session prefix", |
| 126 | ); |
| 127 | |
| 128 | // Hydrated structured messages: reload resolves the recorded display (the |
| 129 | // serialized slash form) while submit is the composed model text, so badge |
| 130 | // restoration relies on the leading-known-token fallback. |
| 131 | const segmentNames = (display: string, submit: string, metadata: Record<string, { kind: "skill" | "subagent" }>) => |
| 132 | invocationSegmentsFromMessage(display, submit, metadata) |
| 133 | .map((segment) => segment.type === "invocation" ? `[${segment.invocation.name}]` : segment.content) |
| 134 | .join("|"); |
| 135 | eq( |
| 136 | segmentNames("/my-formatter fix the tests", "plan-wrapped composed body", { "my-formatter": { kind: "subagent" } }), |
| 137 | "[my-formatter]|fix the tests", |
| 138 | "hydrated slash-form display restores its leading known invocation", |
| 139 | ); |
| 140 | eq( |
| 141 | segmentNames("/my-formatter /explore fix", "composed body", { "my-formatter": { kind: "subagent" }, explore: { kind: "subagent" } }), |
| 142 | "[my-formatter]|[explore]|fix", |
| 143 | "hydrated display restores consecutive leading invocations", |
| 144 | ); |
| 145 | eq( |
| 146 | segmentNames("/unknown-name fix", "composed body", {}), |
| 147 | "/unknown-name fix", |
| 148 | "unknown leading slash names stay plain text after reload", |
| 149 | ); |
| 150 | eq( |
| 151 | segmentNames("see /my-formatter later", "composed body", { "my-formatter": { kind: "subagent" } }), |
| 152 | "see /my-formatter later", |
| 153 | "mid-text slash tokens stay plain text after reload", |
| 154 | ); |
| 155 | |
| 156 | console.log(`\n${passed} passed, ${failed} failed`); |
| 157 | if (failed > 0) process.exit(1); |
| 158 |