diff --git a/.changeset/session-plan-artifacts.md b/.changeset/session-plan-artifacts.md new file mode 100644 index 000000000..5976234e7 --- /dev/null +++ b/.changeset/session-plan-artifacts.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added a session-scoped artifact lifecycle to the CLI and VS Code: implementation plans with explicit review and prose-plan fallback persistence, persistent task tracking, completion walkthroughs enforced after an approved plan, clickable artifact shortcuts that survive session resume, and reliable cancellation recovery. Thanks to @2409324124. Closes #805. diff --git a/assets/nanocoder-vscode.vsix b/assets/nanocoder-vscode.vsix index 53f7e1eca..62a08b678 100644 Binary files a/assets/nanocoder-vscode.vsix and b/assets/nanocoder-vscode.vsix differ diff --git a/plugins/vscode/media/chat-panel.css b/plugins/vscode/media/chat-panel.css index c4799c307..019470ace 100644 --- a/plugins/vscode/media/chat-panel.css +++ b/plugins/vscode/media/chat-panel.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-10{top:-2.5rem}.bottom-24{bottom:6rem}.bottom-\[calc\(100\%\+8px\)\]{bottom:calc(100% + 8px)}.left-0{left:0}.left-1\/2{left:50%}.right-0{right:0}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mr-\[2px\]{margin-right:2px}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-\[1px\]{margin-top:1px}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-24{height:6rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.max-h-64{max-height:16rem}.max-h-\[250px\]{max-height:250px}.max-h-\[calc\(100vh-100px\)\]{max-height:calc(100vh - 100px)}.max-h-full{max-height:100%}.min-h-\[44px\]{min-height:44px}.w-12{width:3rem}.w-24{width:6rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[15\%\]{max-width:15%}.max-w-\[30\%\]{max-width:30%}.max-w-\[40\%\]{max-width:40%}.max-w-\[85\%\]{max-width:85%}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-\[3px\]{border-left-width:3px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-vscode-border{border-color:var(--vscode-panel-border,hsla(0,0%,50%,.2))}.border-vscode-button-secondary{border-color:var(--vscode-button-secondaryBackground)}.border-vscode-focusBorder{border-color:var(--vscode-focusBorder)}.border-vscode-input-border{border-color:var(--vscode-input-border,transparent)}.border-vscode-input-focus{border-color:var(--vscode-focusBorder)}.border-vscode-widget-border{border-color:var(--vscode-widget-border)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-transparent{background-color:transparent}.bg-vscode-bg{background-color:var(--vscode-editor-background)}.bg-vscode-button-bg{background-color:var(--vscode-button-background)}.bg-vscode-button-secondary{background-color:var(--vscode-button-secondaryBackground)}.bg-vscode-dropdown-bg{background-color:var(--vscode-dropdown-background)}.bg-vscode-input-bg{background-color:var(--vscode-input-background)}.bg-vscode-list-active{background-color:var(--vscode-list-activeSelectionBackground)}.bg-vscode-widget-bg{background-color:var(--vscode-editorWidget-background)}.bg-vscode-widget-header{background-color:var(--vscode-editorWidget-border)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-vscode{font-family:var(--vscode-font-family)}.text-\[0\.75em\]{font-size:.75em}.text-\[0\.78em\]{font-size:.78em}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.8em\]{font-size:.8em}.text-\[0\.95em\]{font-size:.95em}.text-\[0\.9em\]{font-size:.9em}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.text-\[\#3178C6\]{--tw-text-opacity:1;color:rgb(49 120 198/var(--tw-text-opacity,1))}.text-\[\#563D7C\]{--tw-text-opacity:1;color:rgb(86 61 124/var(--tw-text-opacity,1))}.text-\[\#89d185\]{--tw-text-opacity:1;color:rgb(137 209 133/var(--tw-text-opacity,1))}.text-\[\#CB3837\]{--tw-text-opacity:1;color:rgb(203 56 55/var(--tw-text-opacity,1))}.text-\[\#E34F26\]{--tw-text-opacity:1;color:rgb(227 79 38/var(--tw-text-opacity,1))}.text-\[\#F1E05A\]{--tw-text-opacity:1;color:rgb(241 224 90/var(--tw-text-opacity,1))}.text-\[\#cccccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#f14c4c\]{--tw-text-opacity:1;color:rgb(241 76 76/var(--tw-text-opacity,1))}.text-vscode-button-fg{color:var(--vscode-button-foreground)}.text-vscode-dropdown-fg{color:var(--vscode-dropdown-foreground)}.text-vscode-fg{color:var(--vscode-editor-foreground)}.text-vscode-input-fg{color:var(--vscode-input-foreground)}.text-vscode-list-activeFg{color:var(--vscode-list-activeSelectionForeground)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}body,html{height:100%;width:100%;margin:0;padding:0;overflow:hidden;font-family:var(--vscode-font-family);font-size:var(--vscode-font-size)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--vscode-scrollbarSlider-background);border:3px solid transparent;background-clip:padding-box;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:var(--vscode-scrollbarSlider-hoverBackground);border:3px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:var(--vscode-scrollbarSlider-activeBackground);border:3px solid transparent;background-clip:padding-box}select{border:1px solid var(--vscode-dropdown-border,transparent)}select,select option{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground)}.markdown-body p{margin-bottom:.5em;margin-top:0}.markdown-body p:last-child{margin-bottom:0}.markdown-body strong{font-weight:600}.markdown-body ul{list-style-type:disc}.markdown-body ol,.markdown-body ul{padding-left:1.5em;margin-bottom:.75em}.markdown-body ol{list-style-type:decimal}.markdown-body li{margin-bottom:.25em}.markdown-body code{font-family:var(--vscode-editor-font-family,monospace);padding:.1em .3em;border-radius:3px;font-size:.9em}.markdown-body code,.markdown-body pre{background-color:var(--vscode-textCodeBlock-background,rgba(0,0,0,.1))}.markdown-body pre{padding:.75em;border-radius:4px;overflow-x:auto;max-width:100%;margin-bottom:.75em}.markdown-body pre code{background-color:transparent;padding:0;font-size:.85em}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{font-weight:600;margin-top:1em;margin-bottom:.5em}.markdown-body h1{font-size:1.5em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}.context-chip{display:inline-flex;align-items:center;gap:6px;max-width:200px;padding:4px 8px;border-radius:12px;border:1px solid var(--vscode-dropdown-border,hsla(0,0%,59%,.2));background-color:var(--vscode-editorWidget-background);font-size:.85em;font-weight:500;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;transition:all .15s ease}.context-chip:hover{border-color:var(--vscode-focusBorder);opacity:.95}.context-chip .chip-name{overflow:hidden;text-overflow:ellipsis}.context-chip .chip-remove{margin-left:2px;opacity:.5;font-size:1.1em;line-height:1;cursor:pointer;transition:opacity .15s ease}.context-chip .chip-remove:hover{opacity:1}#composer-box.drag-over:after{content:"Drop files or folders";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:2px dashed var(--vscode-focusBorder);border-radius:inherit;background:var(--vscode-input-background);opacity:.92;font-size:.85em;font-weight:600;pointer-events:none;z-index:10}.first\:border-t-0:first-child{border-top-width:0}.empty\:hidden:empty{display:none}.focus-within\:border-vscode-input-focus:focus-within{border-color:var(--vscode-focusBorder)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-black\/80:hover{background-color:rgba(0,0,0,.8)}.hover\:bg-vscode-button-hover:hover{background-color:var(--vscode-button-hoverBackground)}.hover\:bg-vscode-button-secondaryHover:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.hover\:bg-vscode-list-hover:hover{background-color:var(--vscode-list-hoverBackground)}.hover\:bg-vscode-toolbarHover:hover{background-color:var(--vscode-toolbar-hoverBackground,hsla(0,0%,50%,.15))}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.group:hover .group-hover\:opacity-100{opacity:1}.group.is-processing .group-\[\.is-processing\]\:block{display:block}.group.is-processing .group-\[\.is-processing\]\:hidden{display:none}.\[\&\.is-processing\]\:bg-vscode-button-secondary.is-processing{background-color:var(--vscode-button-secondaryBackground)}.\[\&\.is-processing\]\:hover\:bg-vscode-button-secondaryHover:hover.is-processing{background-color:var(--vscode-button-secondaryHoverBackground)}.\[\&_svg\]\:mr-0 svg{margin-right:0}.\[\&_svg\]\:h-6 svg{height:1.5rem}.\[\&_svg\]\:w-6 svg{width:1.5rem} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-10{top:-2.5rem}.bottom-24{bottom:6rem}.bottom-\[calc\(100\%\+8px\)\]{bottom:calc(100% + 8px)}.left-0{left:0}.left-1\/2{left:50%}.right-0{right:0}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mr-\[2px\]{margin-right:2px}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-\[1px\]{margin-top:1px}.\!block{display:block!important}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-24{height:6rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.max-h-64{max-height:16rem}.max-h-\[250px\]{max-height:250px}.max-h-\[calc\(100vh-100px\)\]{max-height:calc(100vh - 100px)}.max-h-full{max-height:100%}.min-h-\[44px\]{min-height:44px}.w-12{width:3rem}.w-24{width:6rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[15\%\]{max-width:15%}.max-w-\[30\%\]{max-width:30%}.max-w-\[40\%\]{max-width:40%}.max-w-\[85\%\]{max-width:85%}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-\[3px\]{border-left-width:3px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-vscode-border{border-color:var(--vscode-panel-border,hsla(0,0%,50%,.2))}.border-vscode-button-secondary{border-color:var(--vscode-button-secondaryBackground)}.border-vscode-focusBorder{border-color:var(--vscode-focusBorder)}.border-vscode-input-border{border-color:var(--vscode-input-border,transparent)}.border-vscode-input-focus{border-color:var(--vscode-focusBorder)}.border-vscode-widget-border{border-color:var(--vscode-widget-border)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-transparent{background-color:transparent}.bg-vscode-bg{background-color:var(--vscode-editor-background)}.bg-vscode-button-bg{background-color:var(--vscode-button-background)}.bg-vscode-button-secondary{background-color:var(--vscode-button-secondaryBackground)}.bg-vscode-dropdown-bg{background-color:var(--vscode-dropdown-background)}.bg-vscode-input-bg{background-color:var(--vscode-input-background)}.bg-vscode-list-active{background-color:var(--vscode-list-activeSelectionBackground)}.bg-vscode-widget-bg{background-color:var(--vscode-editorWidget-background)}.bg-vscode-widget-header{background-color:var(--vscode-editorWidget-border)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-vscode{font-family:var(--vscode-font-family)}.text-\[0\.75em\]{font-size:.75em}.text-\[0\.78em\]{font-size:.78em}.text-\[0\.82em\]{font-size:.82em}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.8em\]{font-size:.8em}.text-\[0\.95em\]{font-size:.95em}.text-\[0\.9em\]{font-size:.9em}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.text-\[\#3178C6\]{--tw-text-opacity:1;color:rgb(49 120 198/var(--tw-text-opacity,1))}.text-\[\#563D7C\]{--tw-text-opacity:1;color:rgb(86 61 124/var(--tw-text-opacity,1))}.text-\[\#89d185\]{--tw-text-opacity:1;color:rgb(137 209 133/var(--tw-text-opacity,1))}.text-\[\#CB3837\]{--tw-text-opacity:1;color:rgb(203 56 55/var(--tw-text-opacity,1))}.text-\[\#E34F26\]{--tw-text-opacity:1;color:rgb(227 79 38/var(--tw-text-opacity,1))}.text-\[\#F1E05A\]{--tw-text-opacity:1;color:rgb(241 224 90/var(--tw-text-opacity,1))}.text-\[\#cccccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#f14c4c\]{--tw-text-opacity:1;color:rgb(241 76 76/var(--tw-text-opacity,1))}.text-vscode-button-fg{color:var(--vscode-button-foreground)}.text-vscode-dropdown-fg{color:var(--vscode-dropdown-foreground)}.text-vscode-fg{color:var(--vscode-editor-foreground)}.text-vscode-input-fg{color:var(--vscode-input-foreground)}.text-vscode-list-activeFg{color:var(--vscode-list-activeSelectionForeground)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}body,html{height:100%;width:100%;margin:0;padding:0;overflow:hidden;font-family:var(--vscode-font-family);font-size:var(--vscode-font-size)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--vscode-scrollbarSlider-background);border:3px solid transparent;background-clip:padding-box;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:var(--vscode-scrollbarSlider-hoverBackground);border:3px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:var(--vscode-scrollbarSlider-activeBackground);border:3px solid transparent;background-clip:padding-box}select{border:1px solid var(--vscode-dropdown-border,transparent)}select,select option{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground)}.markdown-body p{margin-bottom:.5em;margin-top:0}.markdown-body p:last-child{margin-bottom:0}.markdown-body strong{font-weight:600}.markdown-body ul{list-style-type:disc}.markdown-body ol,.markdown-body ul{padding-left:1.5em;margin-bottom:.75em}.markdown-body ol{list-style-type:decimal}.markdown-body li{margin-bottom:.25em}.markdown-body code{font-family:var(--vscode-editor-font-family,monospace);padding:.1em .3em;border-radius:3px;font-size:.9em}.markdown-body code,.markdown-body pre{background-color:var(--vscode-textCodeBlock-background,rgba(0,0,0,.1))}.markdown-body pre{padding:.75em;border-radius:4px;overflow-x:auto;max-width:100%;margin-bottom:.75em}.markdown-body pre code{background-color:transparent;padding:0;font-size:.85em}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{font-weight:600;margin-top:1em;margin-bottom:.5em}.markdown-body h1{font-size:1.5em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}.context-chip{display:inline-flex;align-items:center;gap:6px;max-width:200px;padding:4px 8px;border-radius:12px;border:1px solid var(--vscode-dropdown-border,hsla(0,0%,59%,.2));background-color:var(--vscode-editorWidget-background);font-size:.85em;font-weight:500;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;transition:all .15s ease}.context-chip:hover{border-color:var(--vscode-focusBorder);opacity:.95}.context-chip .chip-name{overflow:hidden;text-overflow:ellipsis}.context-chip .chip-remove{margin-left:2px;opacity:.5;font-size:1.1em;line-height:1;cursor:pointer;transition:opacity .15s ease}.context-chip .chip-remove:hover{opacity:1}#composer-box.drag-over:after{content:"Drop files or folders";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:2px dashed var(--vscode-focusBorder);border-radius:inherit;background:var(--vscode-input-background);opacity:.92;font-size:.85em;font-weight:600;pointer-events:none;z-index:10}.first\:border-t-0:first-child{border-top-width:0}.empty\:hidden:empty{display:none}.focus-within\:border-vscode-input-focus:focus-within{border-color:var(--vscode-focusBorder)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-vscode-focusBorder:hover{border-color:var(--vscode-focusBorder)}.hover\:bg-black\/80:hover{background-color:rgba(0,0,0,.8)}.hover\:bg-vscode-button-hover:hover{background-color:var(--vscode-button-hoverBackground)}.hover\:bg-vscode-button-secondaryHover:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.hover\:bg-vscode-list-hover:hover{background-color:var(--vscode-list-hoverBackground)}.hover\:bg-vscode-toolbarHover:hover{background-color:var(--vscode-toolbar-hoverBackground,hsla(0,0%,50%,.15))}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}.group.is-processing .group-\[\.is-processing\]\:block{display:block}.group.is-processing .group-\[\.is-processing\]\:hidden{display:none}.\[\&\.is-processing\]\:bg-vscode-button-secondary.is-processing{background-color:var(--vscode-button-secondaryBackground)}.\[\&\.is-processing\]\:hover\:bg-vscode-button-secondaryHover:hover.is-processing{background-color:var(--vscode-button-secondaryHoverBackground)}.\[\&_svg\]\:mr-0 svg{margin-right:0}.\[\&_svg\]\:h-6 svg{height:1.5rem}.\[\&_svg\]\:w-6 svg{width:1.5rem} \ No newline at end of file diff --git a/plugins/vscode/media/chat-panel.html b/plugins/vscode/media/chat-panel.html index 712338b35..c2126de85 100644 --- a/plugins/vscode/media/chat-panel.html +++ b/plugins/vscode/media/chat-panel.html @@ -37,6 +37,10 @@
+
diff --git a/plugins/vscode/media/chat-panel.js b/plugins/vscode/media/chat-panel.js index d25379c6a..dd100c34e 100644 --- a/plugins/vscode/media/chat-panel.js +++ b/plugins/vscode/media/chat-panel.js @@ -5,6 +5,8 @@ const messagesContainer = document.getElementById('messages-container'); const chatInput = document.getElementById('chat-input'); const composerBox = document.getElementById('composer-box'); + const artifactBar = document.getElementById('artifact-bar'); + const artifactLinks = document.getElementById('artifact-links'); const contextChipsContainer = document.getElementById('context-chips'); const addMenuBtn = document.getElementById('add-menu-btn'); const addMenuDropdown = document.getElementById('add-menu-dropdown'); @@ -522,6 +524,110 @@ } } + function setPlanReviewActive(active) { + if (active) { + if (typeof closeMention === 'function') closeMention(); + if (typeof closeAllDropdowns === 'function') closeAllDropdowns(); + } + chatInput.disabled = active; + composerBox.classList.toggle('opacity-60', active); + composerBox.classList.toggle('pointer-events-none', active); + } + + function removePlanReview() { + const existing = document.getElementById('plan-review-card'); + if (existing) existing.remove(); + setPlanReviewActive(false); + } + + function renderArtifacts(artifacts) { + if (!artifactBar || !artifactLinks) return; + artifactLinks.innerHTML = ''; + const labels = { + implementation_plan: 'Plan', + task: 'Tasks', + walkthrough: 'Walkthrough', + }; + for (const artifact of Array.isArray(artifacts) ? artifacts : []) { + if (!artifact || !labels[artifact.kind] || typeof artifact.path !== 'string') continue; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'bg-vscode-editor-bg border border-vscode-widget-border hover:border-vscode-focusBorder rounded px-2 py-1 cursor-pointer font-vscode text-[0.78em] text-vscode-fg'; + button.textContent = labels[artifact.kind]; + button.title = artifact.path; + button.onclick = () => { + vscode.postMessage({type: 'openPath', path: artifact.path, kind: 'file'}); + }; + artifactLinks.appendChild(button); + } + const hasArtifacts = artifactLinks.childElementCount > 0; + artifactBar.classList.toggle('hidden', !hasArtifacts); + artifactBar.classList.toggle('flex', hasArtifacts); + } + + function renderPlanReview(artifactPath) { + removePlanReview(); + endCurrentTextBlock(); + + const card = document.createElement('div'); + card.id = 'plan-review-card'; + card.className = 'my-3 border border-vscode-focusBorder rounded-lg bg-vscode-widget-bg overflow-hidden shrink-0'; + + const header = document.createElement('div'); + header.className = 'px-3 py-2 bg-vscode-widget-header border-b border-vscode-widget-border'; + const title = document.createElement('div'); + title.className = 'font-vscode text-[0.95em] font-semibold'; + title.textContent = 'Implementation plan ready'; + const subtitle = document.createElement('div'); + subtitle.className = 'font-vscode text-[0.82em] opacity-65 mt-0.5'; + subtitle.textContent = 'Review the saved plan before implementation begins.'; + header.appendChild(title); + header.appendChild(subtitle); + + const body = document.createElement('div'); + body.className = 'px-3 py-3 flex flex-col gap-2.5'; + const openButton = document.createElement('button'); + openButton.type = 'button'; + openButton.className = 'w-full text-left bg-vscode-editor-bg border border-vscode-widget-border hover:border-vscode-focusBorder rounded px-3 py-2 cursor-pointer font-vscode text-[0.9em] transition-colors'; + openButton.textContent = 'Open implementation_plan.md'; + openButton.title = artifactPath; + openButton.onclick = () => { + vscode.postMessage({type: 'openPath', path: artifactPath, kind: 'file'}); + }; + + const actions = document.createElement('div'); + actions.className = 'flex flex-col gap-1.5'; + const approveButton = document.createElement('button'); + approveButton.type = 'button'; + approveButton.className = 'w-full border-none rounded px-3 py-2 cursor-pointer font-vscode text-[0.9em] bg-vscode-button-bg text-vscode-button-fg hover:bg-vscode-button-hover'; + approveButton.textContent = 'Yes, execute this plan'; + approveButton.onclick = () => { + removePlanReview(); + setProcessing(true); + vscode.postMessage({type: 'approvePlan'}); + }; + + const reviseButton = document.createElement('button'); + reviseButton.type = 'button'; + reviseButton.className = 'w-full bg-transparent border border-vscode-button-secondary text-vscode-fg hover:bg-vscode-button-secondaryHover rounded px-3 py-2 cursor-pointer font-vscode text-[0.9em]'; + reviseButton.textContent = 'No, tell Nanocoder what to change'; + reviseButton.onclick = () => { + removePlanReview(); + vscode.postMessage({type: 'revisePlan'}); + chatInput.focus(); + }; + + actions.appendChild(approveButton); + actions.appendChild(reviseButton); + body.appendChild(openButton); + body.appendChild(actions); + card.appendChild(header); + card.appendChild(body); + messagesContainer.appendChild(card); + setPlanReviewActive(true); + scrollToBottom(); + } + // Shared by the Stop button and Escape so the two can't drift apart. function requestCancel() { vscode.postMessage({ type: 'cancel' }); @@ -808,6 +914,10 @@ return; } + // Keep typed text queued in the composer while the current response is + // still running. The Stop button remains available for cancellation. + if (isProcessing) return; + // Append attached paths as context lines if (attachedPaths.length > 0) { const contextText = attachedPaths @@ -1437,6 +1547,7 @@ currentTurnEl = null; currentTextEl = null; currentTurnText = ''; + removePlanReview(); toolKinds.clear(); agentTurnId = 0; lastAgentRawTurnId = -1; @@ -1463,6 +1574,16 @@ case 'permissionRequested': handlePermissionRequested(message.toolCallId, message.toolCall, message.options); break; + case 'planReviewRequested': + setProcessing(false); + renderPlanReview(message.artifactPath); + break; + case 'planReviewError': + setProcessing(false); + break; + case 'artifactsUpdated': + renderArtifacts(message.artifacts); + break; case 'permissionsCancelled': handlePermissionsCancelled(message.toolCallIds); break; diff --git a/plugins/vscode/src/acp-client.ts b/plugins/vscode/src/acp-client.ts index 028eb2928..c880262eb 100644 --- a/plugins/vscode/src/acp-client.ts +++ b/plugins/vscode/src/acp-client.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import {ClientSideConnection} from '@agentclientprotocol/sdk'; import {AcpStateManager, ACPStatus} from './acp-state'; +import {PromptAttempt} from './prompt-attempt'; // We expect at least the version of the CLI where ACP was introduced const MINIMUM_CLI_VERSION = '0.4.0'; @@ -24,6 +25,7 @@ export class NanocoderAcpClient { /** Fires with the tool call ids whose approval cards should be dismissed. */ public onPermissionsCancelled?: (toolCallIds: string[]) => void; public onStateSync?: (state: StateSyncPayload) => void; + public onSessionArtifacts?: (meta: unknown) => void; public onConnectionReady?: () => void; public currentMode?: string; @@ -34,15 +36,7 @@ export class NanocoderAcpClient { public availableProviders: string[] = []; private pendingPermissions = new Map void>(); - /** - * Set while a cancel is in flight for the current turn. A cancelled prompt() - * rejects (the agent throws to abort its stream), but that's the user's own - * request succeeding, not a failure, so we swallow the toast for it here. - * This is a client-side backstop: older/unrelinked CLI builds may not yet - * resolve cancellation cleanly on their end, so we can't rely solely on the - * agent reporting it as a non-error. - */ - private cancelRequested = false; + private activePrompt?: PromptAttempt; constructor(outputChannel: vscode.OutputChannel, stateManager: AcpStateManager) { this.outputChannel = outputChannel; @@ -193,6 +187,7 @@ export class NanocoderAcpClient { const result = await this.connection.newSession({ cwd, mcpServers: [] }); this._sessionId = result.sessionId; + this.onSessionArtifacts?.(result._meta); // Parse modes and configOptions if (result.modes) { @@ -305,6 +300,7 @@ export class NanocoderAcpClient { // Abandoning the conversation abandons its approval prompts too. this._clearPendingPermissions(); this._sessionId = undefined; + this.onSessionArtifacts?.(undefined); } /** * Send a prompt and return the agent's PromptResponse (carries the @@ -313,7 +309,8 @@ export class NanocoderAcpClient { */ async prompt(text: string, images?: { data: string, mimeType: string }[]): Promise { if (!this.connection || !this._sessionId) return undefined; - this.cancelRequested = false; + const attempt = new PromptAttempt(); + this.activePrompt = attempt; try { const promptData: import('@agentclientprotocol/sdk').ContentBlock[] = [{ type: 'text', text }]; if (images && images.length > 0) { @@ -326,20 +323,23 @@ export class NanocoderAcpClient { prompt: promptData }); } catch (error) { - this.outputChannel.appendLine(`Prompt failed: ${error}`); - if (!this.cancelRequested) { + if (attempt.cancelRequested) { + this.outputChannel.appendLine('Prompt cancelled by user.'); + } else { + this.outputChannel.appendLine(`Prompt failed: ${error}`); vscode.window.showErrorMessage(`Nanocoder prompt failed: ${error}`); } return undefined; } finally { - this.cancelRequested = false; + if (this.activePrompt === attempt) { + this.activePrompt = undefined; + } } } async cancel(): Promise { if (!this.connection || !this._sessionId) return; - this.cancelRequested = true; - // Before the notification, so the map is emptied even if cancel() throws. + this.activePrompt?.cancel(); this._clearPendingPermissions(); try { await this.connection.cancel({ @@ -429,7 +429,16 @@ export class NanocoderAcpClient { this._sessionId = sessionId; const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const cwd = workspaceFolder?.uri.fsPath || process.cwd(); - await this.connection.resumeSession({sessionId, cwd}); + const result = await this.connection.resumeSession({sessionId, cwd}); + if (result.modes) { + this.currentMode = result.modes.currentModeId; + this.availableModes = result.modes.availableModes.map((mode: any) => mode.id); + } + if (result.configOptions) { + this._parseConfigOptions(result.configOptions); + } + this.onSessionArtifacts?.(result._meta); + this.notifyStateSync(); } catch (error) { this.outputChannel.appendLine(`resumeSession failed: ${error}`); vscode.window.showErrorMessage(`Failed to resume session: ${error}`); diff --git a/plugins/vscode/src/artifact-controller.spec.ts b/plugins/vscode/src/artifact-controller.spec.ts new file mode 100644 index 000000000..c9444fa7b --- /dev/null +++ b/plugins/vscode/src/artifact-controller.spec.ts @@ -0,0 +1,68 @@ +import test from 'ava'; +import {ArtifactController} from './artifact-controller'; + +test('ArtifactController collects lifecycle artifacts and replaces them on resume', t => { + const controller = new ArtifactController(); + controller.observeSessionUpdate({ + update: { + sessionUpdate: 'tool_call_update', + _meta: { + 'nanocoder/artifact': { + kind: 'walkthrough', + path: '/tmp/walkthrough.md', + }, + }, + }, + }); + + t.deepEqual(controller.artifacts, [ + {kind: 'walkthrough', path: '/tmp/walkthrough.md'}, + ]); + + controller.replaceFromMeta({ + 'nanocoder/artifacts': [ + {kind: 'task', path: '/tmp/task.md'}, + {kind: 'implementation_plan', path: '/tmp/implementation_plan.md'}, + ], + }); + + t.deepEqual(controller.artifacts, [ + {kind: 'implementation_plan', path: '/tmp/implementation_plan.md'}, + {kind: 'task', path: '/tmp/task.md'}, + ]); +}); + +test('ArtifactController reports only real artifact changes', t => { + const controller = new ArtifactController(); + const update = { + sessionUpdate: 'tool_call_update', + _meta: { + 'nanocoder/artifact': { + kind: 'task', + path: '/tmp/task.md', + }, + }, + }; + + t.true(controller.observeSessionUpdate(update)); + t.false(controller.observeSessionUpdate(update), 'same artifact is not a change'); + t.false( + controller.observeSessionUpdate({ + sessionUpdate: 'agent_message_chunk', + content: {type: 'text', text: 'streamed token'}, + }), + 'streaming updates do not trigger artifact refreshes', + ); + t.true( + controller.observeSessionUpdate({ + ...update, + _meta: { + 'nanocoder/artifact': { + kind: 'task', + path: '/tmp/new-task.md', + }, + }, + }), + 'a changed path is reported', + ); +}); diff --git a/plugins/vscode/src/artifact-controller.ts b/plugins/vscode/src/artifact-controller.ts new file mode 100644 index 000000000..591b804d7 --- /dev/null +++ b/plugins/vscode/src/artifact-controller.ts @@ -0,0 +1,78 @@ +export type ArtifactKind = 'implementation_plan' | 'task' | 'walkthrough'; + +export interface ArtifactDescriptor { + kind: ArtifactKind; + path: string; +} + +const ARTIFACT_ORDER: ArtifactKind[] = [ + 'implementation_plan', + 'task', + 'walkthrough', +]; + +export class ArtifactController { + private readonly byKind = new Map(); + + get artifacts(): ArtifactDescriptor[] { + return ARTIFACT_ORDER.flatMap(kind => { + const artifact = this.byKind.get(kind); + return artifact ? [artifact] : []; + }); + } + + observeSessionUpdate(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false; + const envelope = payload as Record; + const update = + envelope.update && typeof envelope.update === 'object' + ? (envelope.update as Record) + : envelope; + const meta = update._meta; + if (!meta || typeof meta !== 'object') return false; + const artifact = this.parseArtifact( + (meta as Record)['nanocoder/artifact'], + ); + if (!artifact) return false; + + const previous = this.byKind.get(artifact.kind); + if (previous?.path === artifact.path) return false; + + this.byKind.set(artifact.kind, artifact); + return true; + } + + replaceFromMeta(meta: unknown): void { + this.reset(); + if (!meta || typeof meta !== 'object') return; + const artifacts = (meta as Record)[ + 'nanocoder/artifacts' + ]; + if (!Array.isArray(artifacts)) return; + for (const value of artifacts) { + const artifact = this.parseArtifact(value); + if (artifact) this.byKind.set(artifact.kind, artifact); + } + } + + reset(): void { + this.byKind.clear(); + } + + private parseArtifact(value: unknown): ArtifactDescriptor | undefined { + if (!value || typeof value !== 'object') return undefined; + const candidate = value as Record; + if ( + typeof candidate.kind !== 'string' || + !ARTIFACT_ORDER.includes(candidate.kind as ArtifactKind) || + typeof candidate.path !== 'string' || + candidate.path.length === 0 + ) { + return undefined; + } + return { + kind: candidate.kind as ArtifactKind, + path: candidate.path, + }; + } +} diff --git a/plugins/vscode/src/chat-webview-provider.ts b/plugins/vscode/src/chat-webview-provider.ts index 5c28224a0..70d0e0728 100644 --- a/plugins/vscode/src/chat-webview-provider.ts +++ b/plugins/vscode/src/chat-webview-provider.ts @@ -5,6 +5,8 @@ import { WebviewToExtensionMessage, ExtensionToWebviewMessage, MentionItem } fro import { NanocoderAcpClient } from './acp-client'; import { DiffManager } from './diff-manager'; +import {ArtifactController} from './artifact-controller'; +import {PlanReviewController} from './plan-review-controller'; import { searchMentions, MentionSearchDeps } from './mention-search'; import { readCappedFile, readCappedDirectory } from './context-attachment'; @@ -27,6 +29,8 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { private _view?: vscode.WebviewView; private _isWebviewReady = false; + private readonly _planReview = new PlanReviewController(); + private readonly _artifacts = new ArtifactController(); constructor( private readonly _extensionUri: vscode.Uri, @@ -36,6 +40,10 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { ) { // Listen for session updates from ACP this._acpClient.onSessionUpdate = (update: any) => { + this._planReview.observeSessionUpdate(update); + if (this._artifacts.observeSessionUpdate(update)) { + this.postArtifacts(); + } this.handleDiffs(update); this.postMessage({ type: 'acpUpdate', @@ -43,6 +51,11 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { }); }; + this._acpClient.onSessionArtifacts = (meta: unknown) => { + this._artifacts.replaceFromMeta(meta); + this.postArtifacts(); + }; + this._acpClient.onPermissionRequested = (toolCallId: string, toolCall: any, options?: any[]) => { this.handleDiffs(toolCall); this.postMessage({ @@ -105,6 +118,36 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { } } + public resetPlanReview(): void { + this._planReview.reset(); + } + + public resetSessionState(): void { + this._planReview.reset(); + this._artifacts.reset(); + this.postArtifacts(); + } + + private postArtifacts(): void { + this.postMessage({ + type: 'artifactsUpdated', + artifacts: this._artifacts.artifacts, + }); + } + + private postPromptResponse( + response?: import('@agentclientprotocol/sdk').PromptResponse, + ): void { + this.postMessage({ + type: 'acpUpdate', + update: { + sessionUpdate: 'prompt_response', + usage: response?.usage, + cost: (response?._meta as Record | undefined)?.['nanocoder/usage']?.cost, + }, + }); + } + public resolveWebviewView( webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, @@ -161,8 +204,19 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { case 'setMode': this._outputChannel.appendLine(`[Webview] User selected mode: ${message.mode}`); + if (message.mode !== 'plan') { + this._planReview.revise(); + } this._acpClient.setSessionMode(message.mode); break; + case 'approvePlan': + this._outputChannel.appendLine('[Webview] User approved the implementation plan.'); + this._approvePlan(); + break; + case 'revisePlan': + this._outputChannel.appendLine('[Webview] User requested plan revisions.'); + this._planReview.revise(); + break; case 'setProvider': this._outputChannel.appendLine(`[Webview] User selected provider: ${message.provider}`); this._acpClient.setSessionProvider(message.provider).then(() => { @@ -180,6 +234,9 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { break; case 'resumeSession': this._outputChannel.appendLine(`[Webview] User resumed session: ${message.sessionId}`); + this._planReview.reset(); + this._artifacts.reset(); + this.postArtifacts(); this.postMessage({type: 'clear', isLoading: true}); this._acpClient.resumeSession(message.sessionId).finally(() => { this.postMessage({type: 'sessionLoaded'}); @@ -430,6 +487,7 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { // transcript too so the UI matches (the server's confirmation // message then streams into the fresh view). if (text.trim() === '/clear') { + this._planReview.reset(); this.postMessage({type: 'clear'}); } @@ -459,22 +517,53 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { }); const response = await this._acpClient.prompt(expandedText, images); - // Signal turn completion so the Webview can flip back to the send - // button. Forward the per-turn token usage and estimated cost so - // the webview can render the usage indicator under the response. - this.postMessage({ - type: 'acpUpdate', - update: { - sessionUpdate: 'prompt_response', - usage: response?.usage, - cost: (response?._meta as Record | undefined)?.['nanocoder/usage']?.cost, - }, - }); + const review = this._planReview.completeTurn(this._acpClient.currentMode); + if (review) { + this.postMessage({ + type: 'planReviewRequested', + artifactPath: review.artifactPath + }); + } + this.postPromptResponse(response); } catch (error) { this._outputChannel.appendLine(`Prompt execution error: ${error}`); vscode.window.showErrorMessage(`Nanocoder Prompt error: ${error}`); // Always reset the button even on error - this.postMessage({type: 'acpUpdate', update: {sessionUpdate: 'prompt_response'}}); + this.postPromptResponse(); + } + } + + private async _approvePlan(): Promise { + try { + let response: import('@agentclientprotocol/sdk').PromptResponse | undefined; + await this._planReview.approve({ + readFile: async artifactPath => fs.promises.readFile(artifactPath, 'utf8'), + setMode: async mode => { + await this._acpClient.setSessionMode(mode); + if (this._acpClient.currentMode !== mode) { + throw new Error('Unable to exit Plan Mode'); + } + }, + prompt: async message => { + response = await this._acpClient.prompt(message); + if (!response) { + throw new Error('Failed to execute the approved plan'); + } + }, + }); + this.postPromptResponse(response); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this._outputChannel.appendLine(`Plan approval failed: ${message}`); + const review = this._planReview.pendingReview; + if (review) { + this.postMessage({ + type: 'planReviewRequested', + artifactPath: review.artifactPath + }); + } + this.postMessage({type: 'planReviewError', message}); + vscode.window.showErrorMessage(`Nanocoder: Unable to approve plan: ${message}`); } } diff --git a/plugins/vscode/src/extension.ts b/plugins/vscode/src/extension.ts index 9371c1521..47cb0b64d 100644 --- a/plugins/vscode/src/extension.ts +++ b/plugins/vscode/src/extension.ts @@ -99,10 +99,10 @@ export function activate(context: vscode.ExtensionContext) { }), vscode.commands.registerCommand('nanocoder.newChat', () => { acpClient.newChat(); + chatProvider.resetSessionState(); chatProvider.postMessage({type: 'clear'}); outputChannel.appendLine('[Extension] New chat started — session cleared.'); }), - vscode.commands.registerCommand('nanocoder.cancel', () => { outputChannel.appendLine('[Extension] Cancel requested.'); void acpClient.cancel(); diff --git a/plugins/vscode/src/plan-review-controller.spec.ts b/plugins/vscode/src/plan-review-controller.spec.ts new file mode 100644 index 000000000..510cf6ff2 --- /dev/null +++ b/plugins/vscode/src/plan-review-controller.spec.ts @@ -0,0 +1,157 @@ +import test from 'ava'; +import {PlanReviewController} from './plan-review-controller'; + +test('PlanReviewController - offers the completed plan after a plan-mode turn', t => { + const controller = new PlanReviewController(); + const artifactPath = '/tmp/session/implementation_plan.md'; + + controller.observeSessionUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'call-plan', + status: 'completed', + _meta: { + 'nanocoder/artifact': { + kind: 'implementation_plan', + path: artifactPath, + }, + }, + }); + + t.deepEqual(controller.completeTurn('plan'), {artifactPath}); + t.deepEqual(controller.pendingReview, {artifactPath}); +}); + +test('PlanReviewController - approval exits plan mode before executing the persisted plan', async t => { + const controller = new PlanReviewController(); + const calls: string[] = []; + controller.observeSessionUpdate({ + sessionUpdate: 'tool_call_update', + status: 'completed', + _meta: { + 'nanocoder/planArtifact': { + path: '/tmp/session/implementation_plan.md', + }, + }, + }); + controller.completeTurn('plan'); + + await controller.approve({ + readFile: async path => { + calls.push(`read:${path}`); + return '# Persisted plan\n\n1. Build it.'; + }, + setMode: async mode => { + calls.push(`mode:${mode}`); + }, + prompt: async message => { + calls.push(`prompt:${message}`); + }, + }); + + t.deepEqual(calls, [ + 'read:/tmp/session/implementation_plan.md', + 'mode:normal', + 'prompt:The implementation plan below is approved. Proceed with implementing it now.\n\n\n# Persisted plan\n\n1. Build it.\n', + ]); + t.is(controller.pendingReview, undefined); +}); + +test('PlanReviewController - failed approval keeps the plan available for retry', async t => { + const controller = new PlanReviewController(); + const artifactPath = '/tmp/session/implementation_plan.md'; + controller.observeSessionUpdate({ + sessionUpdate: 'tool_call_update', + status: 'completed', + _meta: {'nanocoder/planArtifact': {path: artifactPath}}, + }); + controller.completeTurn('plan'); + + await t.throwsAsync( + controller.approve({ + readFile: async () => '', + setMode: async () => {}, + prompt: async () => {}, + }), + {message: 'The approved plan artifact is missing or empty'}, + ); + t.deepEqual(controller.pendingReview, {artifactPath}); +}); + +test('PlanReviewController - prompt failure restores plan mode and keeps the plan available for retry', async t => { + const controller = new PlanReviewController(); + const artifactPath = '/tmp/session/implementation_plan.md'; + const modes: string[] = []; + controller.observeSessionUpdate({ + sessionUpdate: 'tool_call_update', + status: 'completed', + _meta: {'nanocoder/planArtifact': {path: artifactPath}}, + }); + controller.completeTurn('plan'); + + await t.throwsAsync( + controller.approve({ + readFile: async () => '# Persisted plan\n\n1. Build it.', + setMode: async mode => { + modes.push(mode); + }, + prompt: async () => { + throw new Error('transport failed'); + }, + }), + {message: 'transport failed'}, + ); + t.deepEqual(modes, ['normal', 'plan']); + t.deepEqual(controller.pendingReview, {artifactPath}); +}); + +test('PlanReviewController - revision clears the card without leaving plan mode', t => { + const controller = new PlanReviewController(); + controller.observeSessionUpdate({ + sessionUpdate: 'tool_call_update', + status: 'completed', + _meta: { + 'nanocoder/planArtifact': { + path: '/tmp/session/implementation_plan.md', + }, + }, + }); + controller.completeTurn('plan'); + + controller.revise(); + + t.is(controller.pendingReview, undefined); +}); + +test('PlanReviewController - does not carry an artifact across a non-plan turn', t => { + const controller = new PlanReviewController(); + controller.observeSessionUpdate({ + sessionUpdate: 'tool_call_update', + status: 'completed', + _meta: { + 'nanocoder/planArtifact': { + path: '/tmp/session/implementation_plan.md', + }, + }, + }); + + t.is(controller.completeTurn('normal'), undefined); + t.is(controller.completeTurn('plan'), undefined); +}); + +test('PlanReviewController - reset drops artifacts from the previous session', t => { + const controller = new PlanReviewController(); + controller.observeSessionUpdate({ + sessionUpdate: 'tool_call_update', + status: 'completed', + _meta: { + 'nanocoder/planArtifact': { + path: '/tmp/old-session/implementation_plan.md', + }, + }, + }); + + controller.reset(); + + t.is(controller.completeTurn('plan'), undefined); + t.is(controller.pendingReview, undefined); +}); diff --git a/plugins/vscode/src/plan-review-controller.ts b/plugins/vscode/src/plan-review-controller.ts new file mode 100644 index 000000000..64c4313c3 --- /dev/null +++ b/plugins/vscode/src/plan-review-controller.ts @@ -0,0 +1,97 @@ +export interface PlanReviewRequest { + artifactPath: string; +} + +export interface PlanApprovalActions { + readFile: (path: string) => Promise; + setMode: (mode: 'normal' | 'plan') => Promise; + prompt: (message: string) => Promise; +} + +export class PlanReviewController { + private completedArtifactPath?: string; + private _pendingReview?: PlanReviewRequest; + + get pendingReview(): PlanReviewRequest | undefined { + return this._pendingReview; + } + + observeSessionUpdate(payload: unknown): void { + if (!payload || typeof payload !== 'object') return; + const envelope = payload as Record; + const update = + envelope.update && typeof envelope.update === 'object' + ? (envelope.update as Record) + : envelope; + if ( + update.sessionUpdate !== 'tool_call_update' || + update.status !== 'completed' + ) { + return; + } + + const meta = update._meta; + if (!meta || typeof meta !== 'object') return; + const metadata = meta as Record; + const genericArtifact = metadata['nanocoder/artifact']; + const genericPlan = + genericArtifact && + typeof genericArtifact === 'object' && + (genericArtifact as Record).kind === + 'implementation_plan' + ? genericArtifact + : undefined; + const artifact = genericPlan ?? metadata['nanocoder/planArtifact']; + if (!artifact || typeof artifact !== 'object') return; + const artifactPath = (artifact as Record).path; + if (typeof artifactPath === 'string' && artifactPath.length > 0) { + this.completedArtifactPath = artifactPath; + } + } + + completeTurn(mode: string | undefined): PlanReviewRequest | undefined { + const artifactPath = this.completedArtifactPath; + this.completedArtifactPath = undefined; + if (mode !== 'plan' || !artifactPath) return undefined; + this._pendingReview = {artifactPath}; + return this._pendingReview; + } + + async approve(actions: PlanApprovalActions): Promise { + const review = this._pendingReview; + if (!review) throw new Error('No implementation plan is awaiting review'); + + const plan = await actions.readFile(review.artifactPath); + if (!plan.trim()) { + throw new Error('The approved plan artifact is missing or empty'); + } + + const approvedMessage = + 'The implementation plan below is approved. Proceed with implementing it now.\n\n' + + `\n${plan}\n`; + await actions.setMode('normal'); + try { + await actions.prompt(approvedMessage); + } catch (error) { + // Approval is transactional from the UI's perspective: if the + // implementation prompt never starts, put the session back in Plan + // Mode so the restored review card can be revised as well as retried. + try { + await actions.setMode('plan'); + } catch { + // Preserve the original prompt failure; mode restoration is best-effort. + } + throw error; + } + this._pendingReview = undefined; + } + + revise(): void { + this.reset(); + } + + reset(): void { + this.completedArtifactPath = undefined; + this._pendingReview = undefined; + } +} diff --git a/plugins/vscode/src/prompt-attempt.spec.ts b/plugins/vscode/src/prompt-attempt.spec.ts new file mode 100644 index 000000000..81bd460ce --- /dev/null +++ b/plugins/vscode/src/prompt-attempt.spec.ts @@ -0,0 +1,10 @@ +import test from 'ava'; +import {PromptAttempt} from './prompt-attempt'; + +test('PromptAttempt - records an expected cancellation', t => { + const attempt = new PromptAttempt(); + + t.false(attempt.cancelRequested); + attempt.cancel(); + t.true(attempt.cancelRequested); +}); diff --git a/plugins/vscode/src/prompt-attempt.ts b/plugins/vscode/src/prompt-attempt.ts new file mode 100644 index 000000000..5bc2d5b89 --- /dev/null +++ b/plugins/vscode/src/prompt-attempt.ts @@ -0,0 +1,11 @@ +export class PromptAttempt { + private cancelled = false; + + get cancelRequested(): boolean { + return this.cancelled; + } + + cancel(): void { + this.cancelled = true; + } +} diff --git a/plugins/vscode/src/webview-protocol.ts b/plugins/vscode/src/webview-protocol.ts index 723cdcd03..3eb91c1b2 100644 --- a/plugins/vscode/src/webview-protocol.ts +++ b/plugins/vscode/src/webview-protocol.ts @@ -106,6 +106,24 @@ export interface ExtensionMessagePathInfoResolved { kind: 'file' | 'folder'; } +export interface ExtensionMessagePlanReviewRequested { + type: 'planReviewRequested'; + artifactPath: string; +} + +export interface ExtensionMessagePlanReviewError { + type: 'planReviewError'; + message: string; +} + +export interface ExtensionMessageArtifactsUpdated { + type: 'artifactsUpdated'; + artifacts: Array<{ + kind: 'implementation_plan' | 'task' | 'walkthrough'; + path: string; + }>; +} + /** One `@` autocomplete suggestion. */ export interface MentionItem { /** Absolute path — what the composer stores in `attachedPaths`. */ @@ -148,6 +166,9 @@ export type ExtensionToWebviewMessage = | ExtensionMessageUpdateSessions | ExtensionMessageSessionLoaded | ExtensionMessagePathInfoResolved + | ExtensionMessagePlanReviewRequested + | ExtensionMessagePlanReviewError + | ExtensionMessageArtifactsUpdated | ExtensionMessageCopyLastCodeBlock | ExtensionMessageCopyResult | ExtensionMessageMentionCompletions; @@ -248,6 +269,14 @@ export interface WebviewMessageShowError { message: string; } +export interface WebviewMessageApprovePlan { + type: 'approvePlan'; +} + +export interface WebviewMessageRevisePlan { + type: 'revisePlan'; +} + export interface WebviewMessageCopyToClipboard { type: 'copyToClipboard'; text: string; @@ -286,5 +315,7 @@ export type WebviewToExtensionMessage = | WebviewMessageRequestOpenDialog | WebviewMessageOpenPath | WebviewMessageShowError + | WebviewMessageApprovePlan + | WebviewMessageRevisePlan | WebviewMessageCopyToClipboard | WebviewMessageRequestMentionCompletions; diff --git a/source/acp/acp-agent.spec.ts b/source/acp/acp-agent.spec.ts index 576843758..76c207d6b 100644 --- a/source/acp/acp-agent.spec.ts +++ b/source/acp/acp-agent.spec.ts @@ -2,6 +2,7 @@ import {mkdirSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; +import {artifactManager} from '@/artifacts/artifact-manager'; import {AcpAgent} from '@/acp/acp-agent'; import type {AcpInitContext} from '@/acp/acp-types'; import {clearAppConfig} from '@/config'; @@ -42,6 +43,7 @@ const createMockInitContext = (): AcpInitContext => ({ }), getAvailableModels: async () => ['test-model', 'other-model'], getCurrentModel: () => mockCurrentModel, + getProviderConfig: () => ({name: 'test-provider'}), setModel: (model: string) => { mockCurrentModel = model; }, @@ -244,6 +246,111 @@ test('AcpAgent.loadSession - replays in-memory history for a known session', asy t.true(replayed.some(u => u.update.content.text === 'remember this')); }); +test('AcpAgent.loadSession - hides internal walkthrough fallback messages', async t => { + const conn = createMockConn(); + const updates: any[] = []; + conn.sessionUpdate = async (update: any) => { + updates.push(update); + }; + const initContext = createMockInitContext(); + initContext.toolManager = { + getAvailableToolNames: () => ['write_walkthrough'], + getFilteredTools: () => ({}), + hasTool: () => false, + getToolEntry: () => undefined, + } as any; + const agent = new AcpAgent(initContext, conn); + const session = await agent.newSession({cwd: '/tmp'}); + await agent.prompt({ + sessionId: session.sessionId, + prompt: [ + { + type: 'text', + text: 'Implement artifacts.', + }, + ], + }); + + updates.length = 0; + await agent.loadSession({ + sessionId: session.sessionId, + cwd: '/tmp', + mcpServers: [], + }); + const replayedUserText = updates + .filter(update => update.update?.sessionUpdate === 'user_message_chunk') + .map(update => update.update.content.text); + + t.true(replayedUserText.some(text => text.includes(''))); + t.false( + replayedUserText.some(text => text.includes('nanocoder-internal-walkthrough')), + ); +}); + +test('AcpAgent.loadSession - returns the session artifact inventory', async t => { + const {agent} = createAgent(); + const session = await agent.newSession({cwd: '/tmp'}); + + try { + await agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'Persist this session.'}], + }); + await artifactManager.writeArtifact( + session.sessionId, + 'implementation_plan', + '# Plan\n', + ); + await artifactManager.writeArtifact( + session.sessionId, + 'walkthrough', + '# Walkthrough\n', + ); + + const result = await agent.loadSession({ + sessionId: session.sessionId, + cwd: '/tmp', + mcpServers: [], + }); + + const artifacts = result._meta?.['nanocoder/artifacts']; + t.true(Array.isArray(artifacts)); + t.deepEqual( + (artifacts as Array<{kind: string}>).map(artifact => artifact.kind), + ['implementation_plan', 'walkthrough'], + ); + } finally { + await agent.deleteSession({sessionId: session.sessionId}); + } +}); + +test('AcpAgent.resumeSession - returns the session artifact inventory', async t => { + const {agent} = createAgent(); + const session = await agent.newSession({cwd: '/tmp'}); + + try { + await agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'Persist this session.'}], + }); + await artifactManager.writeArtifact( + session.sessionId, + 'task', + '# Tasks\n', + ); + const result = await agent.resumeSession({ + sessionId: session.sessionId, + cwd: '/tmp', + }); + const artifacts = result._meta?.['nanocoder/artifacts'] as Array<{ + kind: string; + }>; + t.deepEqual(artifacts.map(artifact => artifact.kind), ['task']); + } finally { + await agent.deleteSession({sessionId: session.sessionId}); + } +}); + // ============================================================================ // setSessionConfigOption() // ============================================================================ diff --git a/source/acp/acp-agent.ts b/source/acp/acp-agent.ts index be5c273f6..7e0651679 100644 --- a/source/acp/acp-agent.ts +++ b/source/acp/acp-agent.ts @@ -41,6 +41,8 @@ import {runAcpConversation} from '@/acp/acp-conversation'; import {AcpSession} from '@/acp/acp-session'; import type {AcpInitContext} from '@/acp/acp-types'; import {appendToolDefinitionsToPrompt} from '@/ai-sdk-client/tools/system-prompt-assembler'; +import {artifactManager} from '@/artifacts/artifact-manager'; +import {isInternalWalkthroughMessage} from '@/artifacts/walkthrough-lifecycle'; import {createLLMClient} from '@/client-factory'; import {getAppConfig} from '@/config/index'; import {loadPreferences, updateLastUsed} from '@/config/preferences'; @@ -55,6 +57,20 @@ const logger = getLogger(); // Stable id for the model selector config option (category `model`). const MODEL_CONFIG_ID = 'model'; +async function listSessionArtifacts(sessionId: string) { + try { + return await artifactManager.listArtifacts(sessionId); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith('Invalid session ID:') + ) { + return []; + } + throw error; + } +} + export class AcpAgent implements Agent { private sessions = new Map(); private initContext: AcpInitContext; @@ -145,6 +161,9 @@ export class AcpAgent implements Agent { await this.replaySessionHistory(session); return { + _meta: { + 'nanocoder/artifacts': await listSessionArtifacts(params.sessionId), + }, modes: this.buildModeState(session), configOptions: await this.buildConfigOptions(), }; @@ -512,6 +531,9 @@ export class AcpAgent implements Agent { await this.replaySessionHistory(session); return { + _meta: { + 'nanocoder/artifacts': await listSessionArtifacts(params.sessionId), + }, modes: this.buildModeState(session), configOptions: await this.buildConfigOptions(), }; @@ -599,6 +621,7 @@ export class AcpAgent implements Agent { private async replaySessionHistory(session: AcpSession): Promise { for (const message of session.messages) { + if (isInternalWalkthroughMessage(message)) continue; if (message.role === 'user') { if (typeof message.content === 'string' && message.content.length > 0) { await this.conn.sessionUpdate({ diff --git a/source/acp/acp-conversation.spec.ts b/source/acp/acp-conversation.spec.ts index 253b81eef..575d6fc3f 100644 --- a/source/acp/acp-conversation.spec.ts +++ b/source/acp/acp-conversation.spec.ts @@ -45,6 +45,7 @@ const createMockConn = (): { const createMockSession = ( conn: AgentSideConnection, opts: { + sessionId?: string; devMode?: any; messages?: any[]; systemMessage?: any; @@ -52,7 +53,7 @@ const createMockSession = ( } = {}, ): AcpSession => { const session = new AcpSession({ - sessionId: 'test-session', + sessionId: opts.sessionId ?? '00000000-0000-4000-8000-000000000000', cwd: '/tmp', conn, initialMode: opts.devMode ?? 'auto-accept', @@ -145,6 +146,27 @@ test('runAcpConversation - returns cancelled when abort signal is already set', t.is(result.stopReason, 'cancelled'); }); +test('runAcpConversation - treats an aborted model request as cancellation', async t => { + const {conn} = createMockConn(); + const session = createMockSession(conn); + const client = { + chat: async () => { + session.cancel(); + throw new Error('Operation was cancelled'); + }, + } as unknown as LLMClient; + + const result = await runAcpConversation({ + session, + client, + toolManager: createMockToolManager() as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.is(result.stopReason, 'cancelled'); +}); + // ============================================================================ // Empty LLM response // ============================================================================ @@ -422,6 +444,183 @@ test('runAcpConversation - executes tool and emits status updates', async t => { t.truthy(completedUpdate); }); +test('runAcpConversation - forwards the ACP session id to artifact tools', async t => { + const {conn} = createMockConn(); + const session = createMockSession(conn, { + devMode: 'plan', + sessionId: '00000000-0000-4000-8000-000000000001', + }); + const toolManager = { + ...createMockToolManager(), + hasTool: () => true, + getToolEntry: () => ({approval: false}), + }; + + let receivedSessionId: string | undefined; + setToolRegistryGetter(() => ({ + write_plan: async (_args: unknown, options) => { + receivedSessionId = options?.sessionId; + return 'Plan saved'; + }, + })); + + const {client} = createMockClient([ + { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall( + 'write_plan', + {content: '# Plan'}, + 'call-plan', + ), + ], + }, + }, + ], + }, + {choices: [{message: {content: 'Plan ready', tool_calls: []}}]}, + ]); + + await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.is(receivedSessionId, session.sessionId); +}); + +test('runAcpConversation - completed write_plan exposes its artifact location', async t => { + const {conn, updates} = createMockConn(); + const session = createMockSession(conn, { + devMode: 'plan', + sessionId: '00000000-0000-4000-8000-000000000002', + }); + const toolManager = { + ...createMockToolManager(), + hasTool: () => true, + getToolEntry: () => ({approval: false}), + }; + setToolRegistryGetter(() => ({ + write_plan: async () => 'Plan saved', + })); + + const {client} = createMockClient([ + { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall( + 'write_plan', + {content: '# Plan'}, + 'call-plan', + ), + ], + }, + }, + ], + }, + {choices: [{message: {content: 'Plan ready', tool_calls: []}}]}, + ]); + + await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + const completed = updates.find( + (u: any) => + u.update.sessionUpdate === 'tool_call_update' && + u.update.toolCallId === 'call-plan' && + u.update.status === 'completed', + )?.update; + t.truthy(completed); + const artifact = completed._meta?.['nanocoder/planArtifact']; + t.true(artifact.path.endsWith('/implementation_plan.md')); + t.deepEqual(completed._meta?.['nanocoder/artifact'], { + kind: 'implementation_plan', + path: artifact.path, + }); + t.deepEqual(completed.locations, [{path: artifact.path}]); + t.is(completed.title, 'Implementation plan ready'); +}); + +test('runAcpConversation - persists a prose plan when write_plan was omitted', async t => { + const {conn, updates} = createMockConn(); + const session = createMockSession(conn, { + devMode: 'plan', + sessionId: '00000000-0000-4000-8000-000000000003', + }); + const toolManager = { + ...createMockToolManager(), + getAvailableToolNames: () => ['read_file', 'write_plan'], + hasTool: () => true, + getToolEntry: () => ({approval: false}), + }; + + let persistedContent: unknown; + let receivedSessionId: string | undefined; + setToolRegistryGetter(() => ({ + write_plan: async (args: unknown, options) => { + persistedContent = (args as {content?: unknown}).content; + receivedSessionId = options?.sessionId; + return 'Plan saved'; + }, + })); + + const {client} = createMockClient([ + { + choices: [ + { + message: { + content: '# Plan\n\n1. Build it.', + tool_calls: [], + }, + }, + ], + }, + ]); + + const result = await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.is(result.stopReason, 'end_turn'); + t.is(persistedContent, '# Plan\n\n1. Build it.'); + t.is(receivedSessionId, session.sessionId); + const fallbackCall = updates.find( + (u: any) => + u.update.sessionUpdate === 'tool_call' && + u.update.title === 'write_plan', + )?.update; + t.truthy(fallbackCall); + const completed = updates.find( + (u: any) => + u.update.sessionUpdate === 'tool_call_update' && + u.update.toolCallId === fallbackCall.toolCallId && + u.update.status === 'completed', + )?.update; + t.true(completed.locations[0].path.endsWith('/implementation_plan.md')); + t.is( + completed._meta['nanocoder/artifact'].kind, + 'implementation_plan', + ); +}); + // ============================================================================ // write_tasks mirrors to an ACP plan update // ============================================================================ @@ -490,6 +689,239 @@ test('runAcpConversation - write_tasks emits a plan session update', async t => {content: 'Second task', priority: 'medium', status: 'in_progress'}, {content: 'Third task', priority: 'medium', status: 'pending'}, ]); + const taskArtifact = planUpdate.update._meta?.['nanocoder/artifact']; + t.is(taskArtifact.kind, 'task'); + t.true(taskArtifact.path.endsWith('/task.md')); +}); + +test('runAcpConversation - invalid client session IDs omit artifact metadata', async t => { + const {conn, updates} = createMockConn(); + const session = createMockSession(conn, { + devMode: 'yolo', + sessionId: 'external-session', + }); + const toolManager = { + ...createMockToolManager(), + hasTool: () => true, + getToolEntry: () => ({approval: false}), + }; + setToolRegistryGetter(() => ({write_tasks: async () => 'Tasks updated'})); + + let callCount = 0; + const client = { + chat: async () => { + callCount++; + return callCount === 1 + ? { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall( + 'write_tasks', + {tasks: [{title: 'Safe update'}]}, + 'call-invalid-session', + ), + ], + }, + }, + ], + } + : {choices: [{message: {content: 'Done'}}]}; + }, + } as unknown as LLMClient; + + const result = await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.is(result.stopReason, 'end_turn'); + const planUpdate = updates.find( + (update: any) => update.update.sessionUpdate === 'plan', + )?.update; + t.deepEqual(planUpdate.entries, [ + {content: 'Safe update', priority: 'medium', status: 'pending'}, + ]); + t.is(planUpdate._meta, undefined); +}); + +test('runAcpConversation - does not nudge task-only work for a walkthrough', async t => { + const {conn} = createMockConn(); + const session = createMockSession(conn, {devMode: 'yolo'}); + const toolManager = { + ...createMockToolManager(), + getAvailableToolNames: () => ['write_tasks', 'write_walkthrough'], + hasTool: () => true, + getToolEntry: () => ({approval: false}), + }; + + setToolRegistryGetter(() => ({ + write_tasks: async () => 'Tasks updated', + write_walkthrough: async () => 'Walkthrough saved', + })); + + let callCount = 0; + let nudge = ''; + const client = { + chat: async (messages: any[]) => { + callCount++; + if (callCount === 1) { + return { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall('write_tasks', { + tasks: [{title: 'Implement artifacts'}], + }), + ], + }, + }, + ], + }; + } + if (callCount === 2) { + return {choices: [{message: {content: 'Implementation complete.'}}]}; + } + if (callCount === 3) { + nudge = messages.at(-1)?.content ?? ''; + return { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall('write_walkthrough', { + summary: 'Implemented artifacts.', + filesChanged: [], + tests: [], + untestedReason: 'Covered by this test.', + verificationSteps: ['Inspect the artifact.'], + }), + ], + }, + }, + ], + }; + } + return {choices: [{message: {content: 'Walkthrough saved.'}}]}; + }, + } as unknown as LLMClient; + + const result = await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.is(result.stopReason, 'end_turn'); + t.is(callCount, 2); + t.false(nudge.includes('write_walkthrough')); +}); + +test('runAcpConversation - nudges an approved plan for a walkthrough', async t => { + const {conn} = createMockConn(); + const session = createMockSession(conn, { + devMode: 'yolo', + messages: [ + {role: 'user', content: 'Implement it.'}, + ], + }); + const toolManager = { + ...createMockToolManager(), + getAvailableToolNames: () => ['write_walkthrough'], + hasTool: () => true, + getToolEntry: () => ({approval: false}), + }; + setToolRegistryGetter(() => ({ + write_walkthrough: async () => 'Walkthrough saved', + })); + + let callCount = 0; + let nudge = ''; + const client = { + chat: async (messages: any[]) => { + callCount++; + if (callCount === 1) { + return {choices: [{message: {content: 'Implementation complete.'}}]}; + } + if (callCount === 2) { + nudge = messages.at(-1)?.content ?? ''; + return { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall('write_walkthrough', { + summary: 'Implemented the plan.', + filesChanged: [], + tests: [], + untestedReason: 'Covered by this test.', + verificationSteps: ['Inspect the artifact.'], + }), + ], + }, + }, + ], + }; + } + return {choices: [{message: {content: 'Walkthrough saved.'}}]}; + }, + } as unknown as LLMClient; + + const result = await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.is(result.stopReason, 'end_turn'); + t.is(callCount, 3); + t.true(nudge.includes('write_walkthrough')); +}); + +test('runAcpConversation - does not reuse an approved plan from an earlier turn', async t => { + const {conn} = createMockConn(); + const session = createMockSession(conn, { + devMode: 'yolo', + messages: [ + {role: 'user', content: 'Old work.'}, + {role: 'assistant', content: 'Old work complete.'}, + {role: 'user', content: 'What did we change?'}, + ], + }); + const toolManager = { + ...createMockToolManager(), + getAvailableToolNames: () => ['write_walkthrough'], + }; + let callCount = 0; + const client = { + chat: async () => { + callCount++; + return {choices: [{message: {content: 'Here is the explanation.'}}]}; + }, + } as unknown as LLMClient; + + await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.is(callCount, 1); }); test('runAcpConversation - announces every queued tool call before running the batch', async t => { @@ -783,7 +1215,10 @@ test('runAcpConversation - cancelled permission returns cancelled stop reason', { message: { content: '', - tool_calls: [createMockToolCall('dangerous_tool', {}, 'call-1')], + tool_calls: [ + createMockToolCall('dangerous_tool', {}, 'call-1'), + createMockToolCall('queued_tool', {}, 'call-2'), + ], }, }, ], @@ -799,6 +1234,15 @@ test('runAcpConversation - cancelled permission returns cancelled stop reason', }); t.is(result.stopReason, 'cancelled'); + const cancelledResults = session.messages.filter( + (message: any) => message.role === 'tool', + ) as any[]; + t.deepEqual( + cancelledResults.map(message => message.tool_call_id), + ['call-1', 'call-2'], + 'cancelled permission must balance every queued tool call in history', + ); + t.true(cancelledResults.every(message => message.content.includes('cancelled'))); }); // ============================================================================ diff --git a/source/acp/acp-conversation.ts b/source/acp/acp-conversation.ts index edc7ff3f2..42b99f983 100644 --- a/source/acp/acp-conversation.ts +++ b/source/acp/acp-conversation.ts @@ -7,6 +7,16 @@ import {requestToolPermission} from '@/acp/acp-permission'; import {requestUserChoice} from '@/acp/acp-question'; import type {AcpSession} from '@/acp/acp-session'; import {type AcpToolCallMeta, buildToolCallMeta} from '@/acp/acp-tool-call'; +import type { + ArtifactDescriptor, + UserArtifactKind, +} from '@/artifacts/artifact-manager'; +import {artifactManager} from '@/artifacts/artifact-manager'; +import { + createWalkthroughLifecycle, + observeSuccessfulLifecycleTool, + takeWalkthroughFallback, +} from '@/artifacts/walkthrough-lifecycle'; import {DEFAULT_HEADLESS_MAX_TURNS, getAppConfig} from '@/config/index'; import {processToolUse} from '@/message-handler'; import { @@ -28,6 +38,7 @@ import type { } from '@/types/core'; import {buildResponseUsage} from '@/usage/response-usage'; import {capMessagesForModel} from '@/utils/message-capping'; +import {createCancellationResults} from '@/utils/tool-cancellation'; import {toOptionString} from '@/utils/type-helpers'; // On the last allowed turn we strip tools and inject this so the model @@ -37,6 +48,18 @@ const FINAL_TURN_INSTRUCTION = 'Do not call any more tools. Produce your final answer now using only the ' + 'information you already have.'; +const ARTIFACT_TOOL_KINDS: Record = { + write_plan: 'implementation_plan', + write_tasks: 'task', + write_walkthrough: 'walkthrough', +}; + +const ARTIFACT_TITLES: Record = { + implementation_plan: 'Implementation plan ready', + task: 'Tasks updated', + walkthrough: 'Walkthrough ready', +}; + export interface RunAcpConversationOptions { session: AcpSession; client: LLMClient; @@ -53,6 +76,8 @@ export async function runAcpConversation( const {developmentMode, abortController} = session; let messages = session.messages; + const walkthroughLifecycle = createWalkthroughLifecycle(messages); + let wrotePlan = false; // Provider-reported usage accumulated across this prompt's model calls, // returned on the PromptResponse (experimental ACP `usage` field) so @@ -180,13 +205,22 @@ export async function runAcpConversation( ? [{role: 'user', content: FINAL_TURN_INSTRUCTION}] : []; - const result = await client.chat( - [systemMessage, ...cappedMessages, ...finalTurnNotice], - tools, - callbacks, - abortController.signal, - modeOverrides, - ); + let result: Awaited>; + try { + result = await client.chat( + [systemMessage, ...cappedMessages, ...finalTurnNotice], + tools, + callbacks, + abortController.signal, + modeOverrides, + ); + } catch (error) { + if (abortController.signal.aborted) { + session.messages = messages; + return withTurnUsage({stopReason: 'cancelled'}); + } + throw error; + } recordUsage(result?.usage); @@ -247,6 +281,45 @@ export async function runAcpConversation( } if (validToolCalls.length === 0) { + if ( + developmentMode === 'plan' && + !wrotePlan && + availableNames.includes('write_plan') && + cleanedContent.trim().length > 0 + ) { + const fallbackCall: ToolCall = { + id: `write-plan-fallback-${session.sessionId}-${turn}`, + function: { + name: 'write_plan', + arguments: {content: cleanedContent}, + }, + }; + const fallbackResult = await executePlanFallback( + session, + conn, + fallbackCall, + ); + messages = [ + ...messages.slice(0, -1), + { + ...messages[messages.length - 1], + tool_calls: [fallbackCall], + }, + fallbackResult, + ]; + wrotePlan = !fallbackResult.isError; + } + + const fallback = finalTurn + ? null + : takeWalkthroughFallback( + walkthroughLifecycle, + availableNames.includes('write_walkthrough'), + ); + if (fallback) { + messages = [...messages, fallback]; + continue; + } session.messages = messages; return withTurnUsage({stopReason: 'end_turn'}); } @@ -354,9 +427,6 @@ export async function runAcpConversation( 'failed', 'Cancelled by user', ); - // This branch returns instead of falling through to the - // aborted check at the top of the loop, so the calls - // announced behind this one would stay pending forever. if (announcedBatch) { for (const queued of validToolCalls.slice(index + 1)) { await emitToolCallUpdate( @@ -368,6 +438,9 @@ export async function runAcpConversation( ); } } + toolResults.push( + ...createCancellationResults(validToolCalls.slice(index)), + ); session.messages = [...messages, ...toolResults]; return withTurnUsage({stopReason: 'cancelled'}); } @@ -442,6 +515,8 @@ export async function runAcpConversation( const toolResult = await processToolUse(toolCall, { abortSignal: abortController.signal, + sessionId: session.sessionId, + workingDirectory: session.cwd, }); isPolling = false; if (pollInterval) clearInterval(pollInterval); @@ -457,6 +532,12 @@ export async function runAcpConversation( toolResult.content, ); toolResults.push(toolResult); + if (status === 'completed') { + observeSuccessfulLifecycleTool(walkthroughLifecycle, toolCall); + if (toolCall.function.name === 'write_plan') { + wrotePlan = true; + } + } // write_tasks replaces the whole task list; mirror it to the client // as an ACP plan update so GUIs can render a live checklist. @@ -479,6 +560,29 @@ export async function runAcpConversation( return withTurnUsage({stopReason: 'max_turn_requests'}); } +async function executePlanFallback( + session: AcpSession, + conn: AgentSideConnection, + toolCall: ToolCall, +): Promise { + const meta = await buildToolCallMeta(toolCall); + await emitToolCall(session, conn, toolCall, 'pending', meta); + await emitToolCallUpdate(session, conn, toolCall, 'in_progress'); + const result = await processToolUse(toolCall, { + abortSignal: session.abortController.signal, + sessionId: session.sessionId, + workingDirectory: session.cwd, + }); + await emitToolCallUpdate( + session, + conn, + toolCall, + result.isError ? 'failed' : 'completed', + result.content, + ); + return result; +} + /** * Mirror a successful `write_tasks` call to the client as an ACP `plan` * session update. The tool's args carry the complete replacement task list @@ -495,11 +599,19 @@ async function emitPlanUpdate( }; const tasks = Array.isArray(args?.tasks) ? args.tasks : []; const validStatuses = ['pending', 'in_progress', 'completed'] as const; + const taskArtifactPath = artifactManager.tryGetArtifactPath( + session.sessionId, + 'task', + ); + const taskArtifact: ArtifactDescriptor | undefined = taskArtifactPath + ? {kind: 'task', path: taskArtifactPath} + : undefined; await conn.sessionUpdate({ sessionId: session.sessionId, update: { sessionUpdate: 'plan', + _meta: taskArtifact ? {'nanocoder/artifact': taskArtifact} : undefined, entries: tasks .filter(t => typeof t?.title === 'string') .map(t => ({ @@ -551,6 +663,26 @@ async function emitToolCallUpdate( rawOutput?: unknown, title?: string, ): Promise { + const artifactKind = ARTIFACT_TOOL_KINDS[toolCall.function.name]; + const artifactPath = artifactKind + ? artifactManager.tryGetArtifactPath(session.sessionId, artifactKind) + : undefined; + const artifact: ArtifactDescriptor | undefined = + status === 'completed' && artifactKind && artifactPath + ? { + kind: artifactKind, + path: artifactPath, + } + : undefined; + const meta = artifact + ? { + 'nanocoder/artifact': artifact, + ...(artifact.kind === 'implementation_plan' + ? {'nanocoder/planArtifact': {path: artifact.path}} + : {}), + } + : undefined; + await conn.sessionUpdate({ sessionId: session.sessionId, update: { @@ -558,7 +690,9 @@ async function emitToolCallUpdate( toolCallId: toolCall.id, status, rawOutput, - title, + title: artifact ? ARTIFACT_TITLES[artifact.kind] : title, + locations: artifact ? [{path: artifact.path}] : undefined, + _meta: meta, }, }); } diff --git a/source/acp/acp-queued-tools.spec.ts b/source/acp/acp-queued-tools.spec.ts index 355f56c18..c23e972c1 100644 --- a/source/acp/acp-queued-tools.spec.ts +++ b/source/acp/acp-queued-tools.spec.ts @@ -360,3 +360,66 @@ test('runAcpConversation - the queued announcement carries no content', async t t.truthy(withDiff, 'the diff is emitted before the tool runs'); t.is(withDiff.content[0].type, 'diff'); }); + +test('runAcpConversation - cancelled batch tool permission updates ACP cards and balances history without triggering second LLM call', async t => { + const {conn, updates} = createMockConn(); + (conn as any).requestPermission = async () => ({ + outcome: {outcome: 'cancelled'}, + }); + + const session = createMockSession(conn, 'normal'); + const executed: string[] = []; + setToolRegistryGetter(() => ({ + dangerous_tool: async () => { + executed.push('dangerous_tool'); + return 'ran'; + }, + })); + + let llmCallCount = 0; + const result = await runAcpConversation({ + session, + client: { + chat: async () => { + llmCallCount++; + return { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall('dangerous_tool', {}, 'call-1'), + createMockToolCall('dangerous_tool', {}, 'call-2'), + createMockToolCall('dangerous_tool', {}, 'call-3'), + ], + }, + }, + ], + }; + }, + } as unknown as LLMClient, + toolManager: { + ...createMockToolManager(), + getToolEntry: () => ({approval: true}), + } as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.is(result.stopReason, 'cancelled'); + t.is(executed.length, 0, 'nothing runs once permission is cancelled'); + t.is(llmCallCount, 1, 'no second LLM call is triggered'); + + for (const id of ['call-1', 'call-2', 'call-3']) { + const last = updatesFor(updates, id).at(-1); + t.is(last.status, 'failed', `${id} card status must be failed`); + t.is(last.rawOutput, 'Cancelled by user'); + } + + const results = session.messages.filter((m: any) => m.role === 'tool'); + for (const id of ['call-1', 'call-2', 'call-3']) { + const matches = results.filter((m: any) => m.tool_call_id === id); + t.is(matches.length, 1, `exactly one tool result message for ${id}`); + t.regex(matches[0].content, /cancel/i, `${id} tool result content mentions cancellation`); + } +}); diff --git a/source/app/App.tsx b/source/app/App.tsx index 7570e5e69..6ae6fd711 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -319,6 +319,7 @@ export default function App({ subagentsReady: appState.subagentsReady, privacySessionMapRef: appState.privacySessionMapRef, privacyEnabled: getPrivacyPreference(), + ensureCurrentSessionId: appState.ensureCurrentSessionId, }); // Desktop notifications on state transitions. The unified tool flow drives @@ -490,6 +491,8 @@ export default function App({ customCommandCache: appState.customCommandCache, customCommandLoader: appState.customCommandLoader, customCommandExecutor: appState.customCommandExecutor, + currentSessionId: appState.currentSessionId, + ensureCurrentSessionId: appState.ensureCurrentSessionId, onClearCounterIncrement: () => { // Inline mode: /clear must wipe the real terminal (screen + // native scrollback + home) like Claude Code's classic renderer, diff --git a/source/app/prompts/sections/task-approach-nano-plan-readonly.md b/source/app/prompts/sections/task-approach-nano-plan-readonly.md new file mode 100644 index 000000000..88225deeb --- /dev/null +++ b/source/app/prompts/sections/task-approach-nano-plan-readonly.md @@ -0,0 +1,4 @@ +## TASK APPROACH — PLANNING MODE + +- Explore with read-only tools. Do NOT edit, write, or delete files. +- Produce a short markdown plan: summary, files to change, steps, risks. diff --git a/source/app/prompts/sections/task-approach-nano-plan.md b/source/app/prompts/sections/task-approach-nano-plan.md index 1ce73764f..cec2288a4 100644 --- a/source/app/prompts/sections/task-approach-nano-plan.md +++ b/source/app/prompts/sections/task-approach-nano-plan.md @@ -1,4 +1,5 @@ ## TASK APPROACH — PLANNING MODE -- Explore with read-only tools. Do NOT edit, write, or delete files. -- Produce a short markdown plan: summary, files to change, steps, risks. \ No newline at end of file +- Explore with read-only tools. Do NOT edit, write, or delete project files. +- Produce a short markdown plan: summary, files to change, steps, risks. +- Before finishing, call `write_plan` with the complete plan. It is the only permitted write in this mode. diff --git a/source/app/prompts/sections/task-approach-plan-readonly.md b/source/app/prompts/sections/task-approach-plan-readonly.md new file mode 100644 index 000000000..650defc66 --- /dev/null +++ b/source/app/prompts/sections/task-approach-plan-readonly.md @@ -0,0 +1,16 @@ +## TASK APPROACH — PLANNING MODE + +You are in planning mode. Your job is to explore thoroughly and produce a detailed plan — NOT to execute changes. + +0. **Ask before you explore**: If the request is ambiguous about approach, scope, or constraints, use `ask_user` to clarify FIRST — before reading any files. Limit to 1–3 critical questions only (things that would materially change the plan if answered differently). Skip this step if the request is already clear. +1. **Investigate first**: Use read-only tools to explore the codebase. Read relevant files, search for patterns, understand the architecture and dependencies. +2. **Be thorough**: Don't stop at the first file you find. Follow imports, check call sites, understand the full picture before planning. +3. **Produce a structured plan** that includes: + - **Summary**: What needs to happen and why + - **Files to modify**: Every file that needs changes, with a description of what changes + - **Files to create/delete**: If any + - **Step-by-step approach**: Numbered steps in the order they should be executed + - **Dependencies and risks**: What could go wrong, what assumptions you're making + - **Open questions**: Anything ambiguous that needs user input before proceeding +4. **Do NOT make changes**: Do not edit, write, or delete files. Only read and search. +5. **Present the plan clearly**: Use markdown formatting. The user will review and decide what to execute. diff --git a/source/app/prompts/sections/task-approach-plan.md b/source/app/prompts/sections/task-approach-plan.md index 48f214d5b..b7feb44e1 100644 --- a/source/app/prompts/sections/task-approach-plan.md +++ b/source/app/prompts/sections/task-approach-plan.md @@ -12,5 +12,6 @@ You are in planning mode. Your job is to explore thoroughly and produce a detail - **Step-by-step approach**: Numbered steps in the order they should be executed - **Dependencies and risks**: What could go wrong, what assumptions you're making - **Open questions**: Anything ambiguous that needs user input before proceeding -4. **Do NOT make changes**: Do not edit, write, or delete files. Only read and search. -5. **Present the plan clearly**: Use markdown formatting. The user will review and decide what to execute. \ No newline at end of file +4. **Persist the plan**: Call `write_plan` with the COMPLETE Markdown plan before finishing the turn. Each call replaces the prior version, so include every accepted decision and revision. +5. **Do NOT make project changes**: Do not edit, write, or delete project files. `write_plan` is the only permitted write in this mode. +6. **Present the plan clearly**: Use markdown formatting. The user will review and decide what to execute. diff --git a/source/app/prompts/sections/task-management.md b/source/app/prompts/sections/task-management.md index d72f85e04..5166200f0 100644 --- a/source/app/prompts/sections/task-management.md +++ b/source/app/prompts/sections/task-management.md @@ -7,6 +7,6 @@ 2. To start a step, resend the full list with that task set to `in_progress` (keep at most one `in_progress` at a time) 3. To finish a step, resend the full list with that task set to `completed` 4. To add or drop work, resend the list with tasks added or omitted -5. When the request is complete, call `write_tasks` with an empty array to clear the list +5. When the request is complete, leave the completed tasks in place so the session retains its execution record. Clear them only when the user explicitly asks. -Tasks persist in `.nanocoder/tasks.json` across sessions. Running `/clear` resets all tasks. +Tasks persist with the current session outside the working repository. Resuming that session restores them; `/clear` starts a new session without deleting the old task record. diff --git a/source/app/prompts/sections/walkthrough.md b/source/app/prompts/sections/walkthrough.md new file mode 100644 index 000000000..9e1b157a3 --- /dev/null +++ b/source/app/prompts/sections/walkthrough.md @@ -0,0 +1,5 @@ +## COMPLETION WALKTHROUGH + +For complex implementation work, call `write_walkthrough` after the work and verification are complete but before your final answer. Include the files changed, test commands and outcomes, and steps the user can follow to verify the result. + +Only report tests you actually ran. If you did not run tests, leave `tests` empty and provide an honest `untestedReason`. Do not create a walkthrough for simple questions, read-only explanations, cancelled work, or failed work. diff --git a/source/app/sections/interactive-app.spec.tsx b/source/app/sections/interactive-app.spec.tsx index 4e26cfa1a..b8af529c9 100644 --- a/source/app/sections/interactive-app.spec.tsx +++ b/source/app/sections/interactive-app.spec.tsx @@ -37,9 +37,10 @@ interface Overrides { developmentMode?: string; planTurnCompleted?: boolean; setPlanTurnCompleted?: (v: boolean) => void; - pendingPlanProceed?: boolean; - setPendingPlanProceed?: (v: boolean) => void; + pendingPlanProceed?: string | null; + setPendingPlanProceed?: (v: string | null) => void; handleMessageSubmit?: (message: string) => Promise; + currentSessionId?: string | null; } function makeProps(o: Overrides = {}) { @@ -51,6 +52,7 @@ function makeProps(o: Overrides = {}) { messages: o.messages ?? [], currentModel: 'mock-model', currentProvider: 'mock', + currentSessionId: o.currentSessionId ?? null, startChat: o.startChat ?? false, mcpInitialized: true, activeMode: o.activeMode ?? null, @@ -71,7 +73,7 @@ function makeProps(o: Overrides = {}) { setPlanReviewState: o.setPlanReviewState ?? noop, planTurnCompleted: o.planTurnCompleted ?? false, setPlanTurnCompleted: o.setPlanTurnCompleted ?? noop, - pendingPlanProceed: o.pendingPlanProceed ?? false, + pendingPlanProceed: o.pendingPlanProceed ?? null, setPendingPlanProceed: o.setPendingPlanProceed ?? noop, isConversationComplete: o.isConversationComplete ?? false, developmentMode: o.developmentMode ?? 'normal', @@ -124,7 +126,6 @@ function makeProps(o: Overrides = {}) { handleToggleDevelopmentMode: noop, handleMessageSubmit: o.handleMessageSubmit ?? noopAsync, handlePlanProceed: noop, - handlePlanAskMore: noopAsync, handlePlanModify: noop, }, vscodeServer: { @@ -524,6 +525,33 @@ test('plan review bar is shown when planReviewState.show is true', t => { t.regex(lastFrame()!, /Plan ready/); }); +test('plan review bar receives the current session artifact path', t => { + const {lastFrame} = renderWithTheme( + , + ); + + t.regex(lastFrame()!, /implementation_plan\.md/); +}); + +test('plan review bar tolerates an invalid external session ID', t => { + const {lastFrame} = renderWithTheme( + , + ); + + t.regex(lastFrame()!, /Plan ready/); + t.notRegex(lastFrame()!, /implementation_plan\.md/); +}); + test('plan review bar shows when the planTurnCompleted signal fires', async t => { let shown: {show: boolean; originalMessage: string} | null = null; let resetToFalse = false; @@ -577,10 +605,11 @@ test('Proceed dispatches the implement message once mode is normal', async t => renderWithTheme( { - if (v === false) pendingReset = true; + if (v === null) pendingReset = true; }, handleMessageSubmit: async m => { submitted.push(m); @@ -599,7 +628,8 @@ test('Proceed does NOT dispatch while still in plan mode', async t => { renderWithTheme( { submitted.push(m); diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx index 5639ee9fe..b4324a744 100644 --- a/source/app/sections/interactive-app.tsx +++ b/source/app/sections/interactive-app.tsx @@ -3,6 +3,8 @@ import React from 'react'; import {ChatHistory} from '@/app/components/chat-history'; import {ChatInput} from '@/app/components/chat-input'; import {ModalSelectors} from '@/app/components/modal-selectors'; +import {artifactManager} from '@/artifacts/artifact-manager'; +import {SessionArtifactLinks} from '@/components/artifact-links-display'; import {FileExplorer} from '@/components/file-explorer'; import {IdeSelector} from '@/components/ide-selector'; import PlanReviewPrompt from '@/components/plan-review-prompt'; @@ -144,14 +146,13 @@ export function InteractiveApp({ // has propagated, dispatch the "implement the plan" message. Deferring to this // effect is essential — dispatching inside the handler would run the turn with // the stale plan-mode system prompt and tools, so the model would refuse to - // edit. The plan is already in the conversation, so no request text is echoed. + // edit. The approved prompt embeds the plan loaded from the session artifact. React.useEffect(() => { if (!appState.pendingPlanProceed) return; if (appState.developmentMode !== 'normal') return; - appState.setPendingPlanProceed(false); - void appHandlers.handleMessageSubmit( - 'The plan above is approved. Proceed with implementing it now.', - ); + const approvedPlanMessage = appState.pendingPlanProceed; + appState.setPendingPlanProceed(null); + void appHandlers.handleMessageSubmit(approvedPlanMessage); }, [ appState.pendingPlanProceed, appState.developmentMode, @@ -266,6 +267,9 @@ export function InteractiveApp({ // with Static + native scrollback. const fullscreen = altScreenActive; const terminalRows = useTerminalRows(); + const artifactRefreshKey = `${appState.isConversationComplete}:${ + appState.planReviewState?.show ?? false + }:${appState.liveTaskList?.map(task => `${task.id}:${task.status}`).join(',') ?? ''}`; return ( // Fullscreen layout on the alternate screen buffer: the root Box is @@ -300,12 +304,22 @@ export function InteractiveApp({ absorbs ALL vertical shrink — without it Yoga crushes the input box when the transcript is tall. */} + {appState.planReviewState?.show && ( void appHandlers.handlePlanAskMore()} onModify={appHandlers.handlePlanModify} - onDismiss={appHandlers.handlePlanModify} /> )} diff --git a/source/app/utils/app-util.spec.ts b/source/app/utils/app-util.spec.ts index 4bb599ae2..0f473f63f 100644 --- a/source/app/utils/app-util.spec.ts +++ b/source/app/utils/app-util.spec.ts @@ -515,6 +515,39 @@ test('retry command - lazy registry exposes /retry', t => { ); }); +test.serial('/plan is rejected and cannot bypass the Shift+Tab mode cycle', async t => { + const modeChanges: string[] = []; + let queued: React.ReactNode = null; + const options = createResumeTestOptions({ + onAddToChatQueue: component => { + queued = component; + }, + }); + options.developmentMode = 'normal'; + Object.assign(options, { + onSetDevelopmentMode: (mode: string) => { + modeChanges.push(mode); + }, + }); + + await handleMessageSubmission('/plan', options); + await Promise.resolve(); + + t.deepEqual(modeChanges, []); + t.true( + React.isValidElement(queued) && + String((queued.props as {message?: string}).message).includes( + 'Unknown command: plan', + ), + ); +}); + +test('/plan is not discoverable because Shift+Tab is the only mode switch', t => { + const plan = lazyCommands.find(command => command.name === 'plan'); + + t.is(plan, undefined); +}); + test.serial('resume command - /resume with no args enters session selector mode', async t => { let selectorCalled = false; const origInit = sessionManager.initialize.bind(sessionManager); diff --git a/source/app/utils/app-util.ts b/source/app/utils/app-util.ts index 265583618..4ed920f78 100644 --- a/source/app/utils/app-util.ts +++ b/source/app/utils/app-util.ts @@ -8,7 +8,6 @@ import {DELAY_COMMAND_COMPLETE_MS, MAX_SESSION_NAME_LENGTH} from '@/constants'; import {CheckpointManager} from '@/services/checkpoint-manager'; import {generateKey} from '@/session/key-generator'; import {executeBashCommand, formatBashResultForLLM} from '@/tools/execute-bash'; -import {clearAllTasks} from '@/tools/tasks/storage'; import type {ImageAttachment, LLMClient} from '@/types/core'; import type {Message, MessageSubmissionOptions} from '@/types/index'; import {formatError} from '@/utils/error-formatter'; @@ -274,7 +273,6 @@ async function handleSpecialCommand( switch (commandName) { case SPECIAL_COMMANDS.CLEAR: await onClearMessages(); - await clearAllTasks(); // Increment clear counter to force re-render of static components options.onClearCounterIncrement?.(); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); diff --git a/source/artifacts/approved-plan.spec.ts b/source/artifacts/approved-plan.spec.ts new file mode 100644 index 000000000..39030880d --- /dev/null +++ b/source/artifacts/approved-plan.spec.ts @@ -0,0 +1,47 @@ +import {mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'ava'; +import {ArtifactManager} from './artifact-manager'; +import { + createApprovedPlanMessage, + isApprovedPlanMessage, +} from './approved-plan'; + +test('approved execution message is built from the persisted plan', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-approved-plan-')); + const manager = new ArtifactManager(root); + const sessionId = '11111111-1111-4111-8111-111111111111'; + + try { + await manager.writeArtifact( + sessionId, + 'implementation_plan', + '# Persisted plan\n\n1. Change the parser.\n', + ); + + const message = await createApprovedPlanMessage(sessionId, manager); + + t.true(message.includes('# Persisted plan')); + t.true(message.includes('Change the parser')); + t.true(message.includes('approved')); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); + +test('approved execution messages are identified as synthetic user messages', t => { + t.true( + isApprovedPlanMessage({ + role: 'user', + content: + 'The implementation plan below is approved.\n\nImplement it.', + }), + ); + t.false( + isApprovedPlanMessage({ + role: 'assistant', + content: 'Implement it.', + }), + ); +}); diff --git a/source/artifacts/approved-plan.ts b/source/artifacts/approved-plan.ts new file mode 100644 index 000000000..1fc1c1ab4 --- /dev/null +++ b/source/artifacts/approved-plan.ts @@ -0,0 +1,20 @@ +import type {Message} from '@/types/core'; +import {type ArtifactManager, artifactManager} from './artifact-manager'; + +const APPROVED_PLAN_TAG = ''; + +export async function createApprovedPlanMessage( + sessionId: string, + manager: ArtifactManager = artifactManager, +): Promise { + const plan = await manager.readArtifact(sessionId, 'implementation_plan'); + if (!plan?.trim()) { + throw new Error('The approved plan artifact is missing or empty'); + } + + return `The implementation plan below is approved. Proceed with implementing it now.\n\n\n${plan}\n`; +} + +export function isApprovedPlanMessage(message: Message): boolean { + return message.role === 'user' && message.content.includes(APPROVED_PLAN_TAG); +} diff --git a/source/artifacts/artifact-manager.spec.ts b/source/artifacts/artifact-manager.spec.ts new file mode 100644 index 000000000..4909f923d --- /dev/null +++ b/source/artifacts/artifact-manager.spec.ts @@ -0,0 +1,141 @@ +import {mkdtemp, readFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'ava'; +import {ArtifactManager} from './artifact-manager'; + +test('plans are persisted in isolated session directories', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-artifacts-')); + const manager = new ArtifactManager(root); + const firstSession = '11111111-1111-4111-8111-111111111111'; + const secondSession = '22222222-2222-4222-8222-222222222222'; + + try { + const firstPath = await manager.writeArtifact( + firstSession, + 'implementation_plan', + '# First plan\n', + ); + const secondPath = await manager.writeArtifact( + secondSession, + 'implementation_plan', + '# Second plan\n', + ); + + t.is(await readFile(firstPath, 'utf8'), '# First plan\n'); + t.is(await readFile(secondPath, 'utf8'), '# Second plan\n'); + t.not(firstPath, secondPath); + t.is(await manager.readArtifact(firstSession, 'implementation_plan'), '# First plan\n'); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); + +test('deleting one session leaves other session artifacts intact', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-artifacts-')); + const manager = new ArtifactManager(root); + const firstSession = '11111111-1111-4111-8111-111111111111'; + const secondSession = '22222222-2222-4222-8222-222222222222'; + + try { + await manager.writeArtifact(firstSession, 'task', '# First tasks\n'); + await manager.writeArtifact(secondSession, 'task', '# Second tasks\n'); + + await manager.deleteSessionArtifacts(firstSession); + + t.is(await manager.readArtifact(firstSession, 'task'), null); + t.is(await manager.readArtifact(secondSession, 'task'), '# Second tasks\n'); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); + +test('artifact paths reject non-UUID session identifiers', t => { + const manager = new ArtifactManager('/tmp/nanocoder-artifacts-unused'); + + t.throws(() => manager.getArtifactPath('../outside', 'tasks'), { + message: /Invalid session ID/, + }); +}); + +test('artifact paths accept every UUID accepted by session storage', t => { + const manager = new ArtifactManager('/tmp/nanocoder-artifacts-unused'); + const sessionId = '11111111-1111-0111-0111-111111111111'; + + t.true(manager.getArtifactPath(sessionId, 'tasks').endsWith('/tasks.json')); +}); + +test('artifact cleanup ignores invalid external session identifiers', async t => { + const manager = new ArtifactManager('/tmp/nanocoder-artifacts-unused'); + + await t.notThrowsAsync(manager.deleteSessionArtifacts('../outside')); +}); + +test('safe artifact path lookup omits invalid external session identifiers', t => { + const manager = new ArtifactManager('/tmp/nanocoder-artifacts-unused'); + + t.is(manager.tryGetArtifactPath('../outside', 'task'), undefined); + t.true( + manager + .tryGetArtifactPath( + '11111111-1111-4111-8111-111111111111', + 'task', + ) + ?.endsWith('/task.md'), + ); +}); + +test('stale plain artifact cleanup removes only marked dead sessions', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-artifacts-')); + const manager = new ArtifactManager(root); + const persistedSession = '11111111-1111-4111-8111-111111111111'; + const deadPlainSession = '22222222-2222-4222-8222-222222222222'; + const livePlainSession = '33333333-3333-4333-8333-333333333333'; + + try { + await manager.writeArtifact(persistedSession, 'task', '# Persisted\n'); + await manager.markEphemeralSession(deadPlainSession, 101); + await manager.writeArtifact(deadPlainSession, 'task', '# Dead plain\n'); + await manager.markEphemeralSession(livePlainSession, 202); + await manager.writeArtifact(livePlainSession, 'task', '# Live plain\n'); + + await manager.cleanupStaleEphemeralSessions(pid => pid === 202); + + t.is(await manager.readArtifact(persistedSession, 'task'), '# Persisted\n'); + t.is(await manager.readArtifact(deadPlainSession, 'task'), null); + t.is(await manager.readArtifact(livePlainSession, 'task'), '# Live plain\n'); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); + +test('lists only user-facing lifecycle artifacts in order', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-artifacts-')); + const manager = new ArtifactManager(root); + const sessionId = '11111111-1111-4111-8111-111111111111'; + + try { + await manager.writeArtifact(sessionId, 'tasks', '[]'); + await manager.writeArtifact(sessionId, 'task', '# Tasks\n'); + await manager.writeArtifact( + sessionId, + 'implementation_plan', + '# Plan\n', + ); + await manager.writeArtifact(sessionId, 'walkthrough', '# Walkthrough\n'); + + t.deepEqual(await manager.listArtifacts(sessionId), [ + { + kind: 'implementation_plan', + path: manager.getArtifactPath(sessionId, 'implementation_plan'), + }, + {kind: 'task', path: manager.getArtifactPath(sessionId, 'task')}, + { + kind: 'walkthrough', + path: manager.getArtifactPath(sessionId, 'walkthrough'), + }, + ]); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); diff --git a/source/artifacts/artifact-manager.ts b/source/artifacts/artifact-manager.ts new file mode 100644 index 000000000..d9d25dea9 --- /dev/null +++ b/source/artifacts/artifact-manager.ts @@ -0,0 +1,196 @@ +import crypto from 'node:crypto'; +import type {Dirent} from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {getAppDataPath} from '@/config/paths'; +import {isValidSessionId} from '@/session/session-id'; + +const ARTIFACT_FILES = { + implementation_plan: 'implementation_plan.md', + task: 'task.md', + tasks: 'tasks.json', + walkthrough: 'walkthrough.md', +} as const; + +const EPHEMERAL_MARKER = '.ephemeral.json'; + +export type ArtifactKind = keyof typeof ARTIFACT_FILES; +export type UserArtifactKind = Exclude; + +export interface ArtifactDescriptor { + kind: UserArtifactKind; + path: string; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return ( + !(error instanceof Error) || !('code' in error) || error.code !== 'ESRCH' + ); + } +} + +const USER_ARTIFACT_KINDS: UserArtifactKind[] = [ + 'implementation_plan', + 'task', + 'walkthrough', +]; + +export class ArtifactManager { + constructor( + private readonly rootDir = path.join(getAppDataPath(), 'artifacts'), + ) {} + + getArtifactPath(sessionId: string, kind: ArtifactKind): string { + this.validateSessionId(sessionId); + return path.join(this.rootDir, sessionId, ARTIFACT_FILES[kind]); + } + + tryGetArtifactPath( + sessionId: string, + kind: ArtifactKind, + ): string | undefined { + if (!isValidSessionId(sessionId)) return undefined; + return path.join(this.rootDir, sessionId, ARTIFACT_FILES[kind]); + } + + async writeArtifact( + sessionId: string, + kind: ArtifactKind, + content: string, + ): Promise { + const filePath = this.getArtifactPath(sessionId, kind); + const sessionDir = path.dirname(filePath); + await fs.mkdir(sessionDir, {recursive: true, mode: 0o700}); + await fs.chmod(sessionDir, 0o700); + + const temporaryPath = `${filePath}.${crypto.randomUUID()}.tmp`; + try { + await fs.writeFile(temporaryPath, content, { + encoding: 'utf8', + mode: 0o600, + }); + await fs.rename(temporaryPath, filePath); + } catch (error) { + await fs.unlink(temporaryPath).catch(() => {}); + throw error; + } + + return filePath; + } + + async readArtifact( + sessionId: string, + kind: ArtifactKind, + ): Promise { + const filePath = this.getArtifactPath(sessionId, kind); + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + throw error; + } + } + + async listArtifacts(sessionId: string): Promise { + const artifacts: ArtifactDescriptor[] = []; + for (const kind of USER_ARTIFACT_KINDS) { + const artifactPath = this.getArtifactPath(sessionId, kind); + try { + await fs.access(artifactPath); + artifacts.push({kind, path: artifactPath}); + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error; + } + } + } + return artifacts; + } + + async deleteSessionArtifacts(sessionId: string): Promise { + if (!isValidSessionId(sessionId)) return; + await fs.rm(path.join(this.rootDir, sessionId), { + recursive: true, + force: true, + }); + } + + async markEphemeralSession( + sessionId: string, + pid = process.pid, + ): Promise { + this.validateSessionId(sessionId); + const sessionDir = path.join(this.rootDir, sessionId); + await fs.mkdir(sessionDir, {recursive: true, mode: 0o700}); + await fs.chmod(sessionDir, 0o700); + await fs.writeFile( + path.join(sessionDir, EPHEMERAL_MARKER), + JSON.stringify({pid, createdAt: new Date().toISOString()}), + {encoding: 'utf8', mode: 0o600}, + ); + } + + async cleanupStaleEphemeralSessions( + processAlive: (pid: number) => boolean = isProcessAlive, + ): Promise { + let entries: Dirent[]; + try { + entries = await fs.readdir(this.rootDir, {withFileTypes: true}); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return; + } + throw error; + } + + for (const entry of entries) { + if (!entry.isDirectory() || !isValidSessionId(entry.name)) continue; + const markerPath = path.join(this.rootDir, entry.name, EPHEMERAL_MARKER); + let marker: unknown; + try { + marker = JSON.parse(await fs.readFile(markerPath, 'utf8')); + } catch { + continue; + } + const pid = + marker && typeof marker === 'object' + ? (marker as Record).pid + : undefined; + if ( + typeof pid !== 'number' || + !Number.isInteger(pid) || + pid <= 0 || + processAlive(pid) + ) { + continue; + } + await this.deleteSessionArtifacts(entry.name); + } + } + + private validateSessionId(sessionId: string): void { + if (!isValidSessionId(sessionId)) { + throw new Error(`Invalid session ID: ${sessionId}`); + } + } +} + +export const artifactManager = new ArtifactManager(); diff --git a/source/artifacts/walkthrough-lifecycle.ts b/source/artifacts/walkthrough-lifecycle.ts new file mode 100644 index 000000000..835036b7a --- /dev/null +++ b/source/artifacts/walkthrough-lifecycle.ts @@ -0,0 +1,67 @@ +import type {Message, ToolCall} from '@/types/core'; + +const INTERNAL_WALKTHROUGH_PREFIX = ''; + +export interface WalkthroughLifecycle { + required: boolean; + written: boolean; + fallbackAttempted: boolean; +} + +export function createWalkthroughLifecycle( + messages: Message[], +): WalkthroughLifecycle { + let latestUserMessage: Message | undefined; + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === 'user' && !isInternalWalkthroughMessage(message)) { + latestUserMessage = message; + break; + } + } + return { + required: latestUserMessage?.content.includes('') ?? false, + written: false, + fallbackAttempted: false, + }; +} + +export function observeSuccessfulLifecycleTool( + lifecycle: WalkthroughLifecycle, + toolCall: ToolCall, +): void { + if (toolCall.function.name === 'write_walkthrough') { + lifecycle.written = true; + } +} + +export function takeWalkthroughFallback( + lifecycle: WalkthroughLifecycle, + toolAvailable: boolean, +): Message | null { + if ( + !toolAvailable || + !lifecycle.required || + lifecycle.written || + lifecycle.fallbackAttempted + ) { + return null; + } + + lifecycle.fallbackAttempted = true; + return { + role: 'user', + content: + `${INTERNAL_WALKTHROUGH_PREFIX}\n` + + 'Before ending this complex implementation, call write_walkthrough with the files actually changed, tests actually run, and verification steps. ' + + 'After saving it, reply with only a concise confirmation and do not repeat your previous answer.\n' + + '', + }; +} + +export function isInternalWalkthroughMessage(message: Message): boolean { + return ( + message.role === 'user' && + message.content.startsWith(INTERNAL_WALKTHROUGH_PREFIX) + ); +} diff --git a/source/components/artifact-links-display.spec.tsx b/source/components/artifact-links-display.spec.tsx new file mode 100644 index 000000000..d3c12f9f6 --- /dev/null +++ b/source/components/artifact-links-display.spec.tsx @@ -0,0 +1,28 @@ +import test from 'ava'; +import React from 'react'; +import {renderWithTheme} from '../test-utils/render-with-theme'; +import { + ArtifactLinksDisplay, + createTerminalArtifactLink, +} from './artifact-links-display'; + +test('ArtifactLinksDisplay renders clickable lifecycle artifact labels', t => { + const artifacts = [ + {kind: 'implementation_plan' as const, path: '/tmp/implementation_plan.md'}, + {kind: 'task' as const, path: '/tmp/task.md'}, + {kind: 'walkthrough' as const, path: '/tmp/walkthrough.md'}, + ]; + const {lastFrame, unmount} = renderWithTheme( + , + ); + + const frame = lastFrame() ?? ''; + t.true(frame.includes('Artifacts')); + t.true(frame.includes('Plan')); + t.true(frame.includes('Tasks')); + t.true(frame.includes('Walkthrough')); + t.true( + createTerminalArtifactLink(artifacts[1], 'Tasks').includes('file:///tmp/task.md'), + ); + unmount(); +}); diff --git a/source/components/artifact-links-display.tsx b/source/components/artifact-links-display.tsx new file mode 100644 index 000000000..4415ae116 --- /dev/null +++ b/source/components/artifact-links-display.tsx @@ -0,0 +1,73 @@ +import {Box, Text} from 'ink'; +import {useEffect, useState} from 'react'; +import { + type ArtifactDescriptor, + artifactManager, +} from '@/artifacts/artifact-manager'; +import {useTheme} from '@/hooks/useTheme'; +import {createTerminalFileLink} from '@/utils/terminal-file-link'; + +const ARTIFACT_LABELS: Record = { + implementation_plan: 'Plan', + task: 'Tasks', + walkthrough: 'Walkthrough', +}; + +export function createTerminalArtifactLink( + artifact: ArtifactDescriptor, + label = ARTIFACT_LABELS[artifact.kind], +): string { + return createTerminalFileLink(artifact.path, label); +} + +export function ArtifactLinksDisplay({ + artifacts, +}: { + artifacts: ArtifactDescriptor[]; +}) { + const {colors} = useTheme(); + if (artifacts.length === 0) return null; + + return ( + + Artifacts: + {artifacts.map(artifact => ( + + {createTerminalArtifactLink(artifact)} + + ))} + + ); +} + +export function SessionArtifactLinks({ + sessionId, + refreshKey, +}: { + sessionId: string | null; + refreshKey: unknown; +}) { + const [artifacts, setArtifacts] = useState([]); + + useEffect(() => { + void refreshKey; + let cancelled = false; + setArtifacts([]); + if (!sessionId) return () => {}; + + void artifactManager + .listArtifacts(sessionId) + .then(found => { + if (!cancelled) setArtifacts(found); + }) + .catch(() => { + if (!cancelled) setArtifacts([]); + }); + + return () => { + cancelled = true; + }; + }, [sessionId, refreshKey]); + + return ; +} diff --git a/source/components/plan-review-prompt.spec.tsx b/source/components/plan-review-prompt.spec.tsx index 33e9559d4..a7ebcc6e9 100644 --- a/source/components/plan-review-prompt.spec.tsx +++ b/source/components/plan-review-prompt.spec.tsx @@ -1,7 +1,7 @@ import test from 'ava'; import React from 'react'; import {renderWithTheme} from '../test-utils/render-with-theme'; -import PlanReviewPrompt from './plan-review-prompt'; +import PlanReviewPrompt, {createTerminalFileLink} from './plan-review-prompt'; console.log(`\nplan-review-prompt.spec.tsx – ${React.version}`); @@ -13,7 +13,7 @@ const ARROW_DOWN = ''; const ESCAPE = ''; function makeHandlers() { - const calls = {proceed: 0, modify: 0, askMore: 0, dismiss: 0}; + const calls = {proceed: 0, modify: 0}; return { calls, props: { @@ -23,12 +23,6 @@ function makeHandlers() { onModify: () => { calls.modify++; }, - onAskMore: () => { - calls.askMore++; - }, - onDismiss: () => { - calls.dismiss++; - }, }, }; } @@ -39,29 +33,56 @@ const tick = () => new Promise(resolve => setTimeout(resolve, 30)); // Tests // ============================================================================ -test('renders the header and the three action options', t => { +test('renders only the explicit execute-or-revise decisions', t => { const {props} = makeHandlers(); const {lastFrame, unmount} = renderWithTheme(); const output = lastFrame()!; t.regex(output, /Plan ready/); - t.regex(output, /Proceed/); - t.regex(output, /Modify/); - t.regex(output, /Ask more/); + t.regex(output, /Yes, execute this plan/); + t.regex(output, /No, tell Nanocoder what to change/); + t.regex(output, /Executing exits Plan Mode/); + t.regex(output, /requesting changes keeps it active/); + t.notRegex(output, /Ask more/); + unmount(); +}); + +test('shows the persisted plan path and terminal open hint', t => { + const {props} = makeHandlers(); + const artifactPath = '/tmp/implementation_plan.md'; + const {lastFrame, unmount} = renderWithTheme( + , + ); + const output = lastFrame()!; + + t.regex(output, /implementation_plan\.md/); + t.regex(output, /Cmd\/Ctrl\+Click to open/); unmount(); }); +test('creates an OSC 8 file hyperlink with a short non-wrapping label', t => { + const link = createTerminalFileLink('/tmp/implementation plan.md'); + + t.true( + link.includes( + '\u001B]8;;file:///tmp/implementation%20plan.md\u0007', + ), + ); + t.true(link.includes('Open implementation plan.md')); + t.true(link.endsWith('\u001B]8;;\u0007')); +}); + test('shows the highlighted option description, and updates on navigation', async t => { const {props} = makeHandlers(); const {stdin, lastFrame, unmount} = renderWithTheme( , ); await tick(); - // Proceed is highlighted by default. - t.regex(lastFrame()!, /execute the plan/); - // Arrow down to Modify — its description should now show. + // Execute is highlighted by default. + t.regex(lastFrame()!, /Exit Plan Mode/); + // Arrow down to request changes — its description should now show. stdin.write(ARROW_DOWN); await tick(); - t.regex(lastFrame()!, /re-plan/); + t.regex(lastFrame()!, /Stay in Plan Mode/); unmount(); }); @@ -73,7 +94,6 @@ test('Enter selects Proceed (the first option)', async t => { await tick(); t.is(calls.proceed, 1); t.is(calls.modify, 0); - t.is(calls.askMore, 0); unmount(); }); @@ -90,12 +110,12 @@ test('arrow-down then Enter selects Modify', async t => { unmount(); }); -test('Escape dismisses', async t => { +test('Escape takes the revise path instead of ambiguously dismissing', async t => { const {calls, props} = makeHandlers(); const {stdin, unmount} = renderWithTheme(); await tick(); stdin.write(ESCAPE); await tick(); - t.is(calls.dismiss, 1); + t.is(calls.modify, 1); unmount(); }); diff --git a/source/components/plan-review-prompt.tsx b/source/components/plan-review-prompt.tsx index 9ab87961a..ec4f694de 100644 --- a/source/components/plan-review-prompt.tsx +++ b/source/components/plan-review-prompt.tsx @@ -5,31 +5,34 @@ * up/down/Enter SelectInput pattern as the rest of the app (tool confirmation, * selectors) so it stays readable on narrow terminals instead of wrapping a row * of hotkey labels. The highlighted action's description is shown below the - * list; Escape dismisses. + * list; Escape takes the non-executing revision path. * - * Proceed — switch to normal mode and execute the plan - * Modify — stay in plan mode, let the user refine their request - * Ask more — ask additional clarifying questions - * [Esc] — dismiss the prompt, do nothing + * Yes — switch to normal mode and execute the persisted plan + * No — stay in plan mode and let the user request changes + * [Esc] — same as No; never exits Plan Mode implicitly */ +import {basename} from 'node:path'; import {Box, Text, useInput} from 'ink'; import {useState} from 'react'; import {StyledSelectInput} from '@/components/ui/styled-select-input'; import {useTerminalWidth} from '@/hooks/useTerminalWidth'; import {useTheme} from '@/hooks/useTheme'; +import {createTerminalFileLink as createFileLink} from '@/utils/terminal-file-link'; + +export function createTerminalFileLink(filePath: string): string { + return createFileLink(filePath, `Open ${basename(filePath)}`); +} export interface PlanReviewPromptProps { + /** Absolute path of the persisted implementation plan. */ + artifactPath?: string; /** Switch to normal mode and execute the plan. */ onProceed: () => void; /** Stay in plan mode so the user can refine the prompt. */ onModify: () => void; - /** Ask additional clarifying questions. */ - onAskMore: () => void; - /** Dismiss the prompt without any action. */ - onDismiss: () => void; } -type PlanAction = 'proceed' | 'modify' | 'askMore'; +type PlanAction = 'proceed' | 'modify'; interface PlanOption { label: string; @@ -39,46 +42,38 @@ interface PlanOption { const OPTIONS: PlanOption[] = [ { - label: 'Proceed', + label: 'Yes, execute this plan', value: 'proceed', - description: 'Switch to normal mode and execute the plan', + description: 'Exit Plan Mode and begin implementation', }, { - label: 'Modify', + label: 'No, tell Nanocoder what to change', value: 'modify', - description: 'Refine your request and re-plan', - }, - { - label: 'Ask more', - value: 'askMore', - description: 'Answer additional clarifying questions', + description: 'Stay in Plan Mode and revise the plan', }, ]; export default function PlanReviewPrompt({ + artifactPath, onProceed, onModify, - onAskMore, - onDismiss, }: PlanReviewPromptProps) { const {colors} = useTheme(); const boxWidth = useTerminalWidth(); const [highlighted, setHighlighted] = useState('proceed'); - // SelectInput owns up/down/Enter. We only handle Escape (dismiss). + // SelectInput owns up/down/Enter. Escape is the safe, non-executing path. useInput((_input, key) => { if (key.escape) { - onDismiss(); + onModify(); } }); const handleSelect = (item: {value: PlanAction}) => { if (item.value === 'proceed') { onProceed(); - } else if (item.value === 'modify') { - onModify(); } else { - onAskMore(); + onModify(); } }; @@ -106,12 +101,29 @@ export default function PlanReviewPrompt({ What would you like to do? + {artifactPath && ( + + Saved plan: + {artifactPath} + + {createTerminalFileLink(artifactPath)} + + Cmd/Ctrl+Click to open + + )} + setHighlighted(item.value)} /> + + + Executing exits Plan Mode; requesting changes keeps it active. + + + {activeDescription} @@ -120,7 +132,7 @@ export default function PlanReviewPrompt({ - ↑/↓ to move · Enter to select · Esc to dismiss + ↑/↓ to move · Enter to select · Esc to request changes diff --git a/source/hooks/chat-handler/conversation/conversation-loop.spec.ts b/source/hooks/chat-handler/conversation/conversation-loop.spec.ts index ab7f75c9c..12e2ff6a6 100644 --- a/source/hooks/chat-handler/conversation/conversation-loop.spec.ts +++ b/source/hooks/chat-handler/conversation/conversation-loop.spec.ts @@ -1167,6 +1167,52 @@ test.serial('processAssistantResponse - compactRetryCount parameter is passed th // Conversation Complete Tests (lines 509-510) // ============================================================================ +test.serial('processAssistantResponse - nudges once for an approved plan without a walkthrough', async t => { + let chatCallCount = 0; + let nudge = ''; + let completionCount = 0; + const client = { + chat: async (messages: Message[]): Promise => { + chatCallCount++; + if (chatCallCount === 2) { + nudge = messages.at(-1)?.content ?? ''; + } + return { + choices: [ + { + message: { + role: 'assistant', + content: + chatCallCount === 1 ? 'Implementation complete.' : 'Confirmed.', + }, + }, + ], + toolsDisabled: false, + }; + }, + }; + + await processAssistantResponse( + createDefaultParams({ + client, + messages: [ + { + role: 'user', + content: 'Implement artifacts.', + }, + ], + toolManager: createMockToolManager({tools: ['write_walkthrough']}), + onConversationComplete: () => { + completionCount++; + }, + }), + ); + + t.is(chatCallCount, 2); + t.true(nudge.includes('write_walkthrough')); + t.is(completionCount, 1); +}); + test.serial('processAssistantResponse - calls onConversationComplete when done', async t => { let conversationCompleteCalled = false; @@ -1991,4 +2037,4 @@ test.serial('processAssistantResponse - does not start request on orphaned tool if (getAppConfig().sessions && originalMaxMessages !== undefined) { getAppConfig().sessions!.maxMessages = originalMaxMessages; } -}); \ No newline at end of file +}); diff --git a/source/hooks/chat-handler/conversation/conversation-loop.tsx b/source/hooks/chat-handler/conversation/conversation-loop.tsx index 3d477b4e8..6a3728daf 100644 --- a/source/hooks/chat-handler/conversation/conversation-loop.tsx +++ b/source/hooks/chat-handler/conversation/conversation-loop.tsx @@ -1,5 +1,11 @@ import React from 'react'; import type {ConversationStateManager} from '@/app/utils/conversation-state'; +import { + createWalkthroughLifecycle, + observeSuccessfulLifecycleTool, + takeWalkthroughFallback, + type WalkthroughLifecycle, +} from '@/artifacts/walkthrough-lifecycle'; import AssistantMessage from '@/components/assistant-message'; import AssistantReasoning from '@/components/assistant-reasoning'; import {ErrorMessage, InfoMessage} from '@/components/message-box'; @@ -97,7 +103,11 @@ interface ProcessAssistantResponseParams { tune?: TuneConfig; privacySessionMapRef?: React.MutableRefObject>; privacyEnabled?: boolean; + sessionId?: string; + workingDirectory?: string; onPrivacyEvent?: (scrubbedDelta: number) => void; + onToolExecuted?: (toolName: string) => void; + onFinalAssistantText?: (content: string) => void; // Number of consecutive empty assistant turns that have already been // nudged in this loop. The empty-response branch increments and // recurses; every other recursion site resets to 0. @@ -116,6 +126,7 @@ interface ProcessAssistantResponseParams { // How many consecutive turns have emitted the same tool-call signature. // Reaching MAX_REPEATED_TOOL_CALLS stops the loop with an actionable error. repeatedToolCallCount?: number; + walkthroughLifecycle?: WalkthroughLifecycle; } // Module-level flag: show XML fallback notice only once per process lifetime. @@ -187,14 +198,19 @@ export const processAssistantResponse = async ( privacySessionMapRef, privacyEnabled = false, onPrivacyEvent, + onToolExecuted, + sessionId, + workingDirectory, } = params; + const walkthroughLifecycle = + params.walkthroughLifecycle ?? createWalkthroughLifecycle(messages); const startTime = conversationStartTime ?? Date.now(); // Helper to flush live task list to the static chat queue const flushLiveTaskList = async () => { if (!onSetLiveTaskList) return; - const tasks = await loadTasks(); + const tasks = await loadTasks(sessionId); if (tasks.length > 0) { const {TaskListDisplay} = await import('@/components/task-list-display'); addToChatQueue( @@ -766,11 +782,15 @@ export const processAssistantResponse = async ( onSetCompactToolCounts?.({...counts}); } }, - onLiveTaskUpdate: () => { + onLiveTaskUpdate: (tasks?: Task[]) => { hasLiveTaskUpdates = true; - loadTasks().then(tasks => { + if (tasks) { onSetLiveTaskList?.(tasks); - }); + } else { + loadTasks(sessionId).then(loaded => { + onSetLiveTaskList?.(loaded); + }); + } }, nonInteractiveMode, }; @@ -785,9 +805,23 @@ export const processAssistantResponse = async ( toolManager, conversationStateManager, addToChatQueue, - {...displayOptions, setLiveComponent, signal: controller.signal}, + { + ...displayOptions, + setLiveComponent, + signal: controller.signal, + executionContext: {sessionId, workingDirectory}, + }, ); turnResults.push(...directResults); + for (const [index, result] of directResults.entries()) { + if (!result.isError) { + onToolExecuted?.(result.name); + const toolCall = autoTools[index]; + if (toolCall) { + observeSuccessfulLifecycleTool(walkthroughLifecycle, toolCall); + } + } + } } // 2) Non-interactive mode can't prompt, so exit when approval is needed. @@ -819,6 +853,12 @@ export const processAssistantResponse = async ( await flushAll(); setIsGenerating(false); const {processToolUse} = await import('@/message-handler'); + const processToolWithContext = (toolCall: ToolCall) => + processToolUse(toolCall, { + abortSignal: controller.signal, + sessionId, + workingDirectory, + }); for (let i = 0; i < confirmTools.length; i++) { const toolCall = confirmTools[i]; @@ -852,11 +892,15 @@ export const processAssistantResponse = async ( const execution = await executeApprovedTool( toolCall, toolManager, - processToolUse, + processToolWithContext, setLiveComponent, controller.signal, ); turnResults.push(execution.result); + if (!execution.result.isError) { + onToolExecuted?.(execution.result.name); + observeSuccessfulLifecycleTool(walkthroughLifecycle, toolCall); + } await displayExecutedTool( execution, toolManager, @@ -1056,8 +1100,30 @@ export const processAssistantResponse = async ( } if (validToolCalls.length === 0 && cleanedContent.trim()) { + const walkthroughFallback = takeWalkthroughFallback( + walkthroughLifecycle, + availableNames.includes('write_walkthrough'), + ); + if (walkthroughFallback) { + const messagesWithFallback = [...updatedMessages, walkthroughFallback]; + setMessages(messagesWithFallback); + await processAssistantResponse({ + ...params, + abortController: controller, + messages: messagesWithFallback, + conversationStartTime: startTime, + emptyTurnCount: 0, + malformedRetryCount: 0, + lastToolSignature: undefined, + repeatedToolCallCount: 0, + walkthroughLifecycle, + }); + return; + } + // Flush any residual compact counts and task updates from turns that // didn't emit reasoning so they persist in scrollback at conversation end. + params.onFinalAssistantText?.(cleanedContent); await flushAll(); setIsGenerating(false); diff --git a/source/hooks/chat-handler/conversation/tool-executor.spec.ts b/source/hooks/chat-handler/conversation/tool-executor.spec.ts index b4fa636d4..5cebd75d9 100644 --- a/source/hooks/chat-handler/conversation/tool-executor.spec.ts +++ b/source/hooks/chat-handler/conversation/tool-executor.spec.ts @@ -201,6 +201,39 @@ test('executeToolsDirectly - executes tool successfully', async t => { t.true(results[0].content.includes('Tool executed')); }); +test('executeToolsDirectly - forwards the active session context', async t => { + let receivedSessionId: string | undefined; + let receivedWorkingDirectory: string | undefined; + setToolRegistryGetter(() => ({ + ...mockToolHandler, + test_tool: async (_args, options) => { + receivedSessionId = options?.sessionId; + receivedWorkingDirectory = options?.workingDirectory; + return 'Tool executed'; + }, + })); + + try { + await executeToolsDirectly( + [{id: 'call_1', function: {name: 'test_tool', arguments: '{}'}}], + createMockToolManager(), + createMockConversationStateManager() as any, + () => {}, + { + executionContext: { + sessionId: '11111111-1111-4111-8111-111111111111', + workingDirectory: '/workspace', + }, + }, + ); + + t.is(receivedSessionId, '11111111-1111-4111-8111-111111111111'); + t.is(receivedWorkingDirectory, '/workspace'); + } finally { + setToolRegistryGetter(createMockToolRegistry); + } +}); + test('executeToolsDirectly - executes multiple read-only tools in parallel', async t => { const toolCalls: ToolCall[] = [ { @@ -712,6 +745,41 @@ test('executeToolsDirectly - compact mode always expands task tools', async t => t.is(liveTaskUpdateCount, 1, 'Task tool should trigger a live task update'); }); +test('executeToolsDirectly - sends structured tasks to the live list', async t => { + setToolRegistryGetter(() => ({ + ...mockToolHandler, + write_tasks: async () => ({ + llmContent: 'Tasks updated', + structured: { + tasks: [ + { + id: 'task-1', + title: 'Persist plan', + status: 'in_progress', + createdAt: '2026-08-07T00:00:00.000Z', + updatedAt: '2026-08-07T00:00:00.000Z', + }, + ], + }, + }), + })); + let liveTasks: unknown; + + try { + await executeToolsDirectly( + [{id: 'call_1', function: {name: 'write_tasks', arguments: '{}'}}], + createMockToolManager(), + createMockConversationStateManager() as any, + () => {}, + {onLiveTaskUpdate: tasks => (liveTasks = tasks)}, + ); + + t.is((liveTasks as Array<{title: string}>)[0].title, 'Persist plan'); + } finally { + setToolRegistryGetter(createMockToolRegistry); + } +}); + // ============================================================================ // Agent batch signal threading // (regression: parent's abort signal must reach running subagents) @@ -723,9 +791,19 @@ test.serial( const {setAgentToolExecutor} = await import('@/tools/agent-tool'); let received: AbortSignal | undefined; + let receivedContext: + | {sessionId?: string; workingDirectory?: string} + | undefined; setAgentToolExecutor({ - execute: async (_task: unknown, signal?: AbortSignal) => { + execute: async ( + _task: unknown, + signal?: AbortSignal, + _depth?: number, + _agentId?: string, + context?: {sessionId?: string; workingDirectory?: string}, + ) => { received = signal; + receivedContext = context; return { subagentName: 'fake', output: 'ok', @@ -754,10 +832,21 @@ test.serial( createMockToolManager() as any, createMockConversationStateManager() as any, () => {}, - {compactDisplay: true, signal: controller.signal}, + { + compactDisplay: true, + signal: controller.signal, + executionContext: { + sessionId: '11111111-1111-4111-8111-111111111111', + workingDirectory: '/workspace', + }, + }, ); t.is(received, controller.signal); + t.deepEqual(receivedContext, { + sessionId: '11111111-1111-4111-8111-111111111111', + workingDirectory: '/workspace', + }); }, ); diff --git a/source/hooks/chat-handler/conversation/tool-executor.tsx b/source/hooks/chat-handler/conversation/tool-executor.tsx index 84ea777df..0847001dd 100644 --- a/source/hooks/chat-handler/conversation/tool-executor.tsx +++ b/source/hooks/chat-handler/conversation/tool-executor.tsx @@ -13,8 +13,9 @@ import {generateKey} from '@/session/key-generator'; import {MAX_CONCURRENT_AGENTS} from '@/subagents/subagent-executor'; import type {AgentToolArgs} from '@/tools/agent-tool'; import {startAgentExecution} from '@/tools/agent-tool'; +import type {Task} from '@/tools/tasks/types'; import type {ToolManager} from '@/tools/tool-manager'; -import type {ToolCall, ToolResult} from '@/types/core'; +import type {ToolCall, ToolExecutionContext, ToolResult} from '@/types/core'; import {formatError} from '@/utils/error-formatter'; import { runStreamingBashTool, @@ -80,7 +81,7 @@ const executeBashStreaming = async ( export interface ToolDisplayOptions { compactDisplay?: boolean; onCompactToolCount?: (toolName: string) => void; - onLiveTaskUpdate?: () => void; + onLiveTaskUpdate?: (tasks?: Task[]) => void; nonInteractiveMode?: boolean; } @@ -133,7 +134,13 @@ export const displayExecutedTool = async ( !result.content.startsWith('Error: ') ) { // Task tools render in the live area (updating in-place) - options?.onLiveTaskUpdate?.(); + const structured = result.structuredContent as + | {tasks?: unknown} + | undefined; + const tasks = Array.isArray(structured?.tasks) + ? (structured.tasks as Task[]) + : undefined; + options?.onLiveTaskUpdate?.(tasks); } else if ( options?.compactDisplay && !ALWAYS_EXPANDED_TOOLS.has(result.name) @@ -259,6 +266,7 @@ const executeAgentBatch = async ( onCompactToolCount?: (toolName: string) => void, nonInteractiveMode?: boolean, signal?: AbortSignal, + executionContext?: Omit, ): Promise< Array<{ toolCall: ToolCall; @@ -298,7 +306,7 @@ const executeAgentBatch = async ( const {agentId, promise} = startAgentExecution( parsedArgs as unknown as AgentToolArgs, - signal, + {...executionContext, abortSignal: signal}, ); resetSubagentProgressById(agentId); @@ -453,7 +461,7 @@ export const executeToolsDirectly = async ( options?: { compactDisplay?: boolean; onCompactToolCount?: (toolName: string) => void; - onLiveTaskUpdate?: () => void; + onLiveTaskUpdate?: (tasks?: Task[]) => void; setLiveComponent?: (component: React.ReactNode) => void; /** * When true, compact tool results push a one-liner directly to the @@ -467,10 +475,16 @@ export const executeToolsDirectly = async ( * cancel (escape) propagates into running subagents. */ signal?: AbortSignal; + executionContext?: Omit; }, ): Promise => { // Import processToolUse here to avoid circular dependencies const {processToolUse} = await import('@/message-handler'); + const processToolWithContext = (toolCall: ToolCall) => + processToolUse(toolCall, { + ...options?.executionContext, + abortSignal: options?.signal, + }); // Group consecutive parallelizable tools const groups = groupForParallelExecution(toolsToExecuteDirectly, toolManager); @@ -497,6 +511,7 @@ export const executeToolsDirectly = async ( options?.onCompactToolCount, options?.nonInteractiveMode, options?.signal, + options?.executionContext, ); // Agent results are already displayed by executeAgentBatch @@ -513,7 +528,7 @@ export const executeToolsDirectly = async ( if (type === 'readOnly' && group.length > 1) { // Parallel execution for consecutive read-only tools executions = await Promise.all( - group.map(toolCall => executeOne(toolCall, processToolUse)), + group.map(toolCall => executeOne(toolCall, processToolWithContext)), ); } else { // Sequential execution for non-parallelizable tools (or single-item groups) @@ -523,7 +538,7 @@ export const executeToolsDirectly = async ( await executeApprovedTool( toolCall, toolManager, - processToolUse, + processToolWithContext, options?.setLiveComponent, options?.signal, ), diff --git a/source/hooks/chat-handler/types.ts b/source/hooks/chat-handler/types.ts index ca88c4bd4..05c84bf7f 100644 --- a/source/hooks/chat-handler/types.ts +++ b/source/hooks/chat-handler/types.ts @@ -55,6 +55,8 @@ export interface UseChatHandlerProps { subagentsReady?: boolean; privacySessionMapRef?: React.MutableRefObject>; privacyEnabled?: boolean; + /** Ensure tool calls in this turn share the persisted conversation ID. */ + ensureCurrentSessionId?: () => string; } export interface ChatHandlerReturn { diff --git a/source/hooks/chat-handler/useChatHandler.spec.tsx b/source/hooks/chat-handler/useChatHandler.spec.tsx index 74e5a6cac..c3af9893b 100644 --- a/source/hooks/chat-handler/useChatHandler.spec.tsx +++ b/source/hooks/chat-handler/useChatHandler.spec.tsx @@ -1,6 +1,7 @@ import test from 'ava'; import React from 'react'; import {render} from 'ink-testing-library'; +import {setToolRegistryGetter} from '@/message-handler'; import {getBaseSystemPrompt, useChatHandler} from './useChatHandler'; import type {UseChatHandlerProps, ChatHandlerReturn} from './types'; import type {LLMClient, Message} from '../../types/core'; @@ -417,7 +418,7 @@ test('useChatHandler - drains queued message when setup fails before conversatio rendered.unmount(); }); -test('useChatHandler - fires onPlanTurnComplete when a plan-mode turn completes', async t => { +test('useChatHandler - does not offer review when plan mode wrote no artifact', async t => { let planComplete = 0; let hookResult: ChatHandlerReturn | null = null; const customCommandLoader = { @@ -443,7 +444,190 @@ test('useChatHandler - fires onPlanTurnComplete when a plan-mode turn completes' await waitForCondition(() => hookResult !== null); await hookResult!.handleChatMessage('make a plan'); - t.is(planComplete, 1); + t.is(planComplete, 0); +}); + +test('useChatHandler - offers review after write_plan succeeds', async t => { + let planComplete = 0; + let hookResult: ChatHandlerReturn | null = null; + let callCount = 0; + const client: LLMClient = { + ...createMockClient(), + chat: async (_messages, _tools, callbacks) => { + callbacks.onFinish?.(); + callCount++; + return { + choices: [ + { + message: + callCount === 1 + ? { + role: 'assistant' as const, + content: '', + tool_calls: [ + { + id: 'write-plan', + function: { + name: 'write_plan', + arguments: {content: '# Plan'}, + }, + }, + ], + } + : {role: 'assistant' as const, content: 'Plan ready'}, + }, + ], + }; + }, + }; + const toolManager = { + ...createMockToolManager(), + getAvailableToolNames: () => ['write_plan'], + getToolNames: () => ['write_plan'], + hasTool: (name: string) => name === 'write_plan', + getToolEntry: () => ({ + name: 'write_plan', + approval: false, + readOnly: false, + }), + isReadOnly: () => false, + getToolFormatter: () => undefined, + } as unknown as NonNullable; + const customCommandLoader = { + findRelevantCommands: () => [], + } as unknown as NonNullable; + setToolRegistryGetter(() => ({write_plan: async () => 'Plan saved'})); + + try { + render( + + '11111111-1111-4111-8111-111111111111', + onPlanTurnComplete: () => { + planComplete++; + }, + })} + onResult={result => { + hookResult = result; + }} + />, + ); + + await waitForCondition(() => hookResult !== null); + await hookResult!.handleChatMessage('make a plan'); + t.is(planComplete, 1); + } finally { + setToolRegistryGetter(() => ({})); + } +}); + +test('useChatHandler - persists a prose plan when write_plan was omitted', async t => { + let planComplete = 0; + let persistedContent = ''; + let persistedSessionId: string | undefined; + let hookResult: ChatHandlerReturn | null = null; + const client: LLMClient = { + ...createMockClient(), + chat: async (_messages, _tools, callbacks) => { + callbacks.onFinish?.(); + return { + choices: [ + { + message: { + role: 'assistant' as const, + content: '# Plan\n\n1. Build it.', + }, + }, + ], + }; + }, + }; + const toolManager = { + ...createMockToolManager(), + getAvailableToolNames: () => ['write_plan'], + getToolNames: () => ['write_plan'], + hasTool: (name: string) => name === 'write_plan', + getToolEntry: () => ({ + name: 'write_plan', + approval: false, + readOnly: false, + }), + isReadOnly: () => false, + getToolFormatter: () => undefined, + } as unknown as NonNullable; + const customCommandLoader = { + findRelevantCommands: () => [], + } as unknown as NonNullable; + setToolRegistryGetter(() => ({ + write_plan: async (args, options) => { + persistedContent = args.content; + persistedSessionId = options?.sessionId; + return 'Plan saved'; + }, + })); + + try { + render( + + '11111111-1111-4111-8111-111111111111', + onPlanTurnComplete: () => { + planComplete++; + }, + })} + onResult={result => { + hookResult = result; + }} + />, + ); + + await waitForCondition(() => hookResult !== null); + await hookResult!.handleChatMessage('make a plan'); + t.is(persistedContent, '# Plan\n\n1. Build it.'); + t.is(persistedSessionId, '11111111-1111-4111-8111-111111111111'); + t.is(planComplete, 1); + } finally { + setToolRegistryGetter(() => ({})); + } +}); + +test('useChatHandler - allocates a session before starting a turn', async t => { + let ensureCalls = 0; + let hookResult: ChatHandlerReturn | null = null; + const customCommandLoader = { + findRelevantCommands: () => [], + } as unknown as NonNullable; + + render( + { + ensureCalls++; + return '11111111-1111-4111-8111-111111111111'; + }, + })} + onResult={result => { + hookResult = result; + }} + />, + ); + + await waitForCondition(() => hookResult !== null); + await hookResult!.handleChatMessage('start a session'); + t.is(ensureCalls, 1); }); // The signal must be scoped to plan mode — a normal-mode turn completing must diff --git a/source/hooks/chat-handler/useChatHandler.tsx b/source/hooks/chat-handler/useChatHandler.tsx index 0266f021b..efafc37e0 100644 --- a/source/hooks/chat-handler/useChatHandler.tsx +++ b/source/hooks/chat-handler/useChatHandler.tsx @@ -4,6 +4,7 @@ import {ConversationStateManager} from '@/app/utils/conversation-state'; import UserMessage from '@/components/user-message'; import {getAppConfig} from '@/config/index'; import {CommandIntegration} from '@/custom-commands/command-integration'; +import {processToolUse} from '@/message-handler'; import {generateKey} from '@/session/key-generator'; import {getTuneToolMode} from '@/types/config'; import type {ImageAttachment, Message} from '@/types/core'; @@ -91,6 +92,7 @@ export function useChatHandler({ subagentsReady, privacySessionMapRef, privacyEnabled, + ensureCurrentSessionId, }: UseChatHandlerProps): ChatHandlerReturn { // Conversation state manager for enhanced context const conversationStateManager = React.useRef(new ConversationStateManager()); @@ -214,7 +216,13 @@ export function useChatHandler({ // Wrapper for processAssistantResponse that includes error handling const processAssistantResponseWithErrorHandling = React.useCallback( - async (systemMessage: Message, msgs: Message[]) => { + async ( + systemMessage: Message, + msgs: Message[], + sessionId?: string, + onToolExecuted?: (toolName: string) => void, + onFinalAssistantText?: (content: string) => void, + ) => { if (!client) return; try { @@ -250,6 +258,10 @@ export function useChatHandler({ tune, privacySessionMapRef, privacyEnabled, + sessionId, + workingDirectory: process.cwd(), + onToolExecuted, + onFinalAssistantText, onPrivacyEvent: (count: number) => { // `count` is the number of NEW identifiers scrubbed on this turn // (the per-turn delta), not a session running total. @@ -305,6 +317,9 @@ export function useChatHandler({ images?: ImageAttachment[], ) => { if (!client || !toolManager) return; + const sessionId = ensureCurrentSessionId?.(); + let wrotePlan = false; + let finalAssistantText = ''; // Record conversation start time for elapsed time display conversationStartTimeRef.current = Date.now(); @@ -369,15 +384,53 @@ export function useChatHandler({ await processAssistantResponseWithErrorHandling( systemMessage, updatedMessages, + sessionId, + toolName => { + if (toolName === 'write_plan') wrotePlan = true; + }, + content => { + finalAssistantText = content; + }, ); + if ( + developmentMode === 'plan' && + !wrotePlan && + !controller.signal.aborted && + finalAssistantText.trim() + ) { + const fallbackResult = await processToolUse( + { + id: 'write-plan-fallback', + function: { + name: 'write_plan', + arguments: {content: finalAssistantText}, + }, + }, + { + abortSignal: controller.signal, + sessionId, + workingDirectory: process.cwd(), + }, + ); + if (fallbackResult.isError) { + displayError(new Error(fallbackResult.content), 'plan-fallback'); + } else { + wrotePlan = true; + } + } + // If this turn STARTED in plan mode (closure value, captured at submit // time) and ran to completion without being interrupted, a plan was // actually produced — signal the plan review bar. Deciding here, with // the start mode and the abort signal both in hand, avoids the race // where toggling modes mid-generation makes an unrelated completing turn // look like a finished plan. - if (developmentMode === 'plan' && !controller.signal.aborted) { + if ( + developmentMode === 'plan' && + wrotePlan && + !controller.signal.aborted + ) { onPlanTurnComplete?.(); } } catch (error) { diff --git a/source/hooks/useAppHandlers.spec.tsx b/source/hooks/useAppHandlers.spec.tsx index 9102578f9..50d620b23 100644 --- a/source/hooks/useAppHandlers.spec.tsx +++ b/source/hooks/useAppHandlers.spec.tsx @@ -63,6 +63,9 @@ function makeProps(overrides: ProbeOverrides) { const setCurrentProvider = spy<[string]>(); const setCurrentModel = spy<[string]>(); const setLiveTaskList = spy<[unknown]>(); + const setPlanReviewState = spy< + [{show: boolean; originalMessage: string} | null] + >(); const addToChatQueue = spy<[React.ReactNode]>(); const setChatComponents = spy<[React.ReactNode[]]>(); const setLiveComponent = spy<[React.ReactNode]>(); @@ -94,6 +97,9 @@ function makeProps(overrides: ProbeOverrides) { customCommandCache: new Map(), customCommandLoader: null, customCommandExecutor: null, + currentSessionId: '11111111-1111-4111-8111-111111111111', + ensureCurrentSessionId: () => + '11111111-1111-4111-8111-111111111111', updateMessages, setIsCancelling, setDevelopmentMode, @@ -107,6 +113,7 @@ function makeProps(overrides: ProbeOverrides) { setCurrentProvider, setCurrentModel, setLiveTaskList, + setPlanReviewState, addToChatQueue, setChatComponents, setLiveComponent, @@ -145,6 +152,7 @@ function makeProps(overrides: ProbeOverrides) { setCurrentSessionId, setChatComponents, addToChatQueue, + setPlanReviewState, dismissActiveEditor, handleModelSelect, }, @@ -222,6 +230,28 @@ test('handleToggleDevelopmentMode cycles through modes', t => { t.deepEqual(s4.setDevelopmentMode.calls, [['normal']]); }); +test('declining execution keeps Plan Mode active and asks for revisions', t => { + const {handlers, spies} = setup({developmentMode: 'plan'}); + + handlers.handlePlanModify(); + + t.deepEqual(spies.setPlanReviewState.calls, [[null]]); + t.deepEqual(spies.setDevelopmentMode.calls, []); + const notice = spies.addToChatQueue.calls.at(-1)?.[0]; + t.true( + React.isValidElement(notice) && + String((notice.props as {message?: string}).message).includes( + 'Plan Mode remains active', + ), + ); + t.true( + React.isValidElement(notice) && + String((notice.props as {message?: string}).message).includes( + 'what to change', + ), + ); +}); + async function withMockConfig( config: any, preferences: any, diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx index ee18724c7..6d559c871 100644 --- a/source/hooks/useAppHandlers.tsx +++ b/source/hooks/useAppHandlers.tsx @@ -4,6 +4,7 @@ import { createClearMessagesHandler, handleMessageSubmission, } from '@/app/utils/app-util'; +import {createApprovedPlanMessage} from '@/artifacts/approved-plan'; import { ErrorMessage, SuccessMessage, @@ -26,6 +27,7 @@ import { type GitStatusSummary, getGitStatusSummarySync, } from '@/tools/git/utils'; +import {loadTasks} from '@/tools/tasks/storage'; import type {Task} from '@/tools/tasks/types'; import type { CheckpointListItem, @@ -65,6 +67,8 @@ interface UseAppHandlersProps { customCommandCache: Map; customCommandLoader: CustomCommandLoader | null; customCommandExecutor: CustomCommandExecutor | null; + currentSessionId: string | null; + ensureCurrentSessionId: () => string; // Callbacks onClearCounterIncrement?: () => void; @@ -93,7 +97,7 @@ interface UseAppHandlersProps { setPlanReviewState: ( value: {show: boolean; originalMessage: string} | null, ) => void; - setPendingPlanProceed: (value: boolean) => void; + setPendingPlanProceed: (value: string | null) => void; // Callbacks addToChatQueue: (component: React.ReactNode) => void; @@ -150,8 +154,7 @@ export interface AppHandlers { images?: ImageAttachment[], ) => Promise; // Plan review action bar - handlePlanProceed: () => void; - handlePlanAskMore: () => Promise; + handlePlanProceed: () => Promise; handlePlanModify: () => void; } @@ -532,6 +535,9 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { props.setCurrentModel(session.model); props.setCurrentSessionId(session.id); setKeyGeneratorSessionId(session.id); + void loadTasks(session.id).then(tasks => { + props.setLiveTaskList(tasks.length > 0 ? tasks : null); + }); // Replay the persisted conversation into scrollback so the user can see // what they resumed (prompts, assistant replies, tool activity) instead // of an empty screen with only a success line. @@ -596,37 +602,48 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { }, [props.setActiveMode, props]); // Plan review action bar handlers - const handlePlanProceed = React.useCallback(() => { - // Hide the review bar and switch to normal mode. The actual "implement the - // plan" message is dispatched by an effect once developmentMode has settled - // to 'normal' (see InteractiveApp) — dispatching here would run the turn - // with the stale plan-mode system prompt and tools. We deliberately do NOT - // echo the user's last message: the plan is already in the conversation, and - // after a Modify/clarify round the last message is a follow-up question, not - // the original request. - props.setPlanReviewState(null); - props.setDevelopmentMode('normal'); - props.setPendingPlanProceed(true); + const handlePlanProceed = React.useCallback(async () => { + try { + if (!props.currentSessionId) { + throw new Error('No active session for the plan artifact'); + } + const approvedPlanMessage = await createApprovedPlanMessage( + props.currentSessionId, + ); + // The effect in InteractiveApp waits for this mode change before it + // submits the persisted plan, preventing a stale plan-mode turn. + props.setPlanReviewState(null); + props.setDevelopmentMode('normal'); + props.setPendingPlanProceed(approvedPlanMessage); + } catch (error) { + props.addToChatQueue( + , + ); + } }, [ + props.currentSessionId, + props.addToChatQueue, props.setPlanReviewState, props.setDevelopmentMode, props.setPendingPlanProceed, props, ]); - const handlePlanAskMore = React.useCallback(async () => { - // Hide the review bar - props.setPlanReviewState(null); - // Stay in plan mode and ask the model to ask additional questions - await props.handleChatMessage( - 'please ask me any additional clarifying questions before proceeding', - ); - }, [props.setPlanReviewState, props.handleChatMessage, props]); - const handlePlanModify = React.useCallback(() => { - // Just dismiss the bar — the user will edit and re-submit + // Return to input without changing mode so the user can request revisions. props.setPlanReviewState(null); - }, [props.setPlanReviewState, props]); + props.addToChatQueue( + , + ); + }, [props.setPlanReviewState, props.addToChatQueue, props]); // Message submit handler const handleMessageSubmit = React.useCallback( @@ -635,6 +652,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { displayValue?: string, images?: ImageAttachment[], ) => { + props.ensureCurrentSessionId(); // Reset conversation completion flag when starting a new message props.setIsConversationComplete(false); @@ -752,7 +770,6 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { handleSessionCancel, handleMessageSubmit, handlePlanProceed, - handlePlanAskMore, handlePlanModify, }; } diff --git a/source/hooks/useAppInitialization.tsx b/source/hooks/useAppInitialization.tsx index e84f25340..791e1e216 100644 --- a/source/hooks/useAppInitialization.tsx +++ b/source/hooks/useAppInitialization.tsx @@ -33,7 +33,6 @@ import {generateKey} from '@/session/key-generator'; import {SubagentExecutor} from '@/subagents/subagent-executor'; import {getSubagentLoader} from '@/subagents/subagent-loader'; import {setAgentToolExecutor, setAvailableAgentNames} from '@/tools/agent-tool'; -import {clearAllTasks} from '@/tools/tasks'; import {ToolManager} from '@/tools/tool-manager'; import type {CustomCommand} from '@/types/commands'; import { @@ -563,10 +562,6 @@ export function useAppInitialization({ setCurrentModel(''); setCurrentProviderConfig(null); - // Clear task list — fire-and-forget, just deletes a JSON file; - // swallow failures so an unwritable cwd can't crash the process - clearAllTasks().catch(() => {}); - const newToolManager = new ToolManager(); const newCustomCommandLoader = new CustomCommandLoader(); const newCustomCommandExecutor = new CustomCommandExecutor(); diff --git a/source/hooks/useAppState.tsx b/source/hooks/useAppState.tsx index e9e9c6d94..9b7170ccd 100644 --- a/source/hooks/useAppState.tsx +++ b/source/hooks/useAppState.tsx @@ -1,3 +1,4 @@ +import {randomUUID} from 'node:crypto'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {TitleShape} from '@/components/ui/styled-title'; import {getAppConfig} from '@/config/index'; @@ -6,6 +7,7 @@ import {defaultTheme} from '@/config/themes'; import {resolveTune} from '@/config/tune'; import {CustomCommandExecutor} from '@/custom-commands/executor'; import {CustomCommandLoader} from '@/custom-commands/loader'; +import {setCliSessionId} from '@/session/cli-session-context'; import {generateKey} from '@/session/key-generator'; import {createTokenizer} from '@/tokenization/index.js'; import type {Task} from '@/tools/tasks/types'; @@ -107,11 +109,13 @@ export function useAppState( // plan mode completes uninterrupted. The interactive UI consumes it to show // the plan review bar (reading the latest messages), then resets it. const [planTurnCompleted, setPlanTurnCompleted] = useState(false); - // One-shot signal: set true when the user hits Proceed on the plan review bar. - // The dispatch of the "implement the plan" message is deferred to an effect + // One-shot approved-plan message created when the user hits Proceed. + // Dispatch is deferred to an effect // that waits for developmentMode to become 'normal', so the executing turn // runs with normal-mode tools/prompt instead of the stale plan-mode closures. - const [pendingPlanProceed, setPendingPlanProceed] = useState(false); + const [pendingPlanProceed, setPendingPlanProceed] = useState( + null, + ); // Cancellation state const [abortController, setAbortController] = @@ -125,7 +129,23 @@ export function useAppState( currentMessageCount: number; } | null>(null); const [showAllSessions, setShowAllSessions] = useState(false); - const [currentSessionId, setCurrentSessionId] = useState(null); + const [currentSessionId, setCurrentSessionIdState] = useState( + null, + ); + const currentSessionIdRef = useRef(null); + const setCurrentSessionId = useCallback((value: string | null) => { + currentSessionIdRef.current = value; + setCliSessionId(value); + setCurrentSessionIdState(value); + }, []); + const ensureCurrentSessionId = useCallback((): string => { + if (currentSessionIdRef.current) return currentSessionIdRef.current; + const id = randomUUID(); + currentSessionIdRef.current = id; + setCliSessionId(id); + setCurrentSessionIdState(id); + return id; + }, []); const [sessionName, setSessionName] = useState(''); const [isToolConfirmationMode, setIsToolConfirmationMode] = useState(false); @@ -358,6 +378,7 @@ export function useAppState( checkpointLoadData, showAllSessions, currentSessionId, + ensureCurrentSessionId, sessionName, isToolConfirmationMode, isToolExecuting, diff --git a/source/hooks/useSessionAutosave.spec.ts b/source/hooks/useSessionAutosave.spec.ts index 08e852708..7b882ae90 100644 --- a/source/hooks/useSessionAutosave.spec.ts +++ b/source/hooks/useSessionAutosave.spec.ts @@ -25,6 +25,10 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; import {SessionManager} from '../session/session-manager.js'; +import { + deriveSessionTitle, + shouldResetSessionId, +} from './useSessionAutosave.js'; import { getKeyGeneratorSessionId, resetKeyGeneratorForTests, @@ -42,6 +46,66 @@ function makeMessages(count: number) { })); } +test('slash-only sessions keep their preallocated ID while messages stay empty', t => { + t.false(shouldResetSessionId(0, 0)); +}); + +test('clearing a non-empty conversation resets its session ID', t => { + t.true(shouldResetSessionId(1, 0)); +}); + +test('internal walkthrough fallback does not replace the user-derived title', t => { + const title = deriveSessionTitle([ + {role: 'user', content: 'Implement the greeting helper'}, + {role: 'assistant', content: 'Implementation complete.'}, + { + role: 'user', + content: + 'Call write_walkthrough before ending.', + }, + ]); + + t.is(title, 'Implement the greeting helper'); +}); + +test('approved plan injection does not replace the user-derived title', t => { + const title = deriveSessionTitle([ + {role: 'user', content: 'Implement the greeting helper'}, + {role: 'assistant', content: 'The plan is ready for approval.'}, + { + role: 'user', + content: + 'The implementation plan below is approved. Proceed with implementing it now.\n\n\nImplement the helper.\n', + }, + {role: 'assistant', content: 'Implementation complete.'}, + { + role: 'user', + content: + 'Call write_walkthrough before ending.', + }, + ]); + + t.is(title, 'Implement the greeting helper'); +}); + +test('session titles keep the existing 50-character truncation', t => { + const content = 'a'.repeat(51); + + t.is(deriveSessionTitle([{role: 'user', content}]), `${'a'.repeat(50)}...`); +}); + +test('an internal walkthrough without a real user message uses the fallback title', t => { + const title = deriveSessionTitle([ + { + role: 'user', + content: + 'Call write_walkthrough before ending.', + }, + ]); + + t.is(title, `Session ${new Date().toLocaleDateString()}`); +}); + // --------------------------------------------------------------------------- // Bug A — Duplicate-session race // --------------------------------------------------------------------------- diff --git a/source/hooks/useSessionAutosave.ts b/source/hooks/useSessionAutosave.ts index f47454750..795fa17ef 100644 --- a/source/hooks/useSessionAutosave.ts +++ b/source/hooks/useSessionAutosave.ts @@ -1,4 +1,6 @@ import {useCallback, useEffect, useRef} from 'react'; +import {isApprovedPlanMessage} from '@/artifacts/approved-plan'; +import {isInternalWalkthroughMessage} from '@/artifacts/walkthrough-lifecycle'; import {getAppConfig} from '@/config/index'; import {sessionManager} from '@/session/session-manager'; import type {Message} from '@/types/core'; @@ -16,10 +18,35 @@ interface UseSessionAutosaveProps { const SHUTDOWN_HANDLER_NAME = 'session-autosave-flush'; +export function shouldResetSessionId( + previousMessageCount: number, + currentMessageCount: number, +): boolean { + return previousMessageCount > 0 && currentMessageCount === 0; +} + +export function deriveSessionTitle(messages: Message[]): string { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if ( + message?.role === 'user' && + !isApprovedPlanMessage(message) && + !isInternalWalkthroughMessage(message) + ) { + return ( + message.content.substring(0, 50) + + (message.content.length > 50 ? '...' : '') + ); + } + } + + return `Session ${new Date().toLocaleDateString()}`; +} + /** * Hook to handle automatic session saving. * Updates the current session when currentSessionId is set; otherwise creates a new session. - * Clears currentSessionId when messages are cleared. + * Clears currentSessionId when a non-empty conversation is cleared. * * Race safety: saves are serialised through a single chained promise stored in * saveChainRef. A new save does not start until the previous one resolves. @@ -78,9 +105,17 @@ export function useSessionAutosave({ modelRef.current = currentModel; }, [currentModel]); - // Clear current session when conversation is cleared + // Clear the current session only on a non-empty -> empty transition. A + // slash-only session can have artifacts before it has chat messages, and its + // preallocated ID must survive subsequent slash commands. + const previousMessageCountRef = useRef(messages.length); useEffect(() => { - if (messages.length === 0 && currentSessionId !== null) { + const previousMessageCount = previousMessageCountRef.current; + previousMessageCountRef.current = messages.length; + if ( + shouldResetSessionId(previousMessageCount, messages.length) && + currentSessionId !== null + ) { setCurrentSessionId(null); } }, [messages.length, currentSessionId, setCurrentSessionId]); @@ -135,14 +170,7 @@ export function useSessionAutosave({ // Derive a human-readable title from the most recent user message. // The full message array is always written - maxMessages bounds only // what is sent to the model (sliced in the conversation loop). - const userMessages = capturedMessages.filter( - msg => msg.role === 'user', - ); - const lastUserMessage = userMessages[userMessages.length - 1]; - const title = lastUserMessage - ? lastUserMessage.content.substring(0, 50) + - (lastUserMessage.content.length > 50 ? '...' : '') - : `Session ${new Date().toLocaleDateString()}`; + const title = deriveSessionTitle(capturedMessages); if (liveSessionId) { const session = await sessionManager.readSession(liveSessionId); @@ -166,6 +194,7 @@ export function useSessionAutosave({ } else { // The stored session was deleted externally; create a fresh one. const newSession = await sessionManager.createSession({ + id: liveSessionId, title, messageCount: capturedMessages.length, provider: capturedProvider, diff --git a/source/message-handler.ts b/source/message-handler.ts index 300178e18..2c322361f 100644 --- a/source/message-handler.ts +++ b/source/message-handler.ts @@ -1,6 +1,11 @@ import type {CustomCommandLoader} from '@/custom-commands/loader'; import type {ToolManager} from '@/tools/tool-manager'; -import type {ToolCall, ToolHandler, ToolResult} from '@/types/index'; +import type { + ToolCall, + ToolExecutionContext, + ToolHandler, + ToolResult, +} from '@/types/index'; import {parseToolArguments} from '@/utils/tool-args-parser'; import {toolErrorToContent} from '@/utils/tool-validation'; import {truncateToolResult} from '@/utils/truncate-tool-result'; @@ -42,7 +47,7 @@ export function getCommandLoader(): CustomCommandLoader | null { export async function processToolUse( toolCall: ToolCall, - options?: {abortSignal?: AbortSignal}, + options?: ToolExecutionContext, ): Promise { // Handle XML validation errors by throwing (will be caught and returned as error ToolResult) if (toolCall.function.name === '__xml_validation_error__') { diff --git a/source/plain/conversation.spec.ts b/source/plain/conversation.spec.ts index 5ded2a4ea..036a78417 100644 --- a/source/plain/conversation.spec.ts +++ b/source/plain/conversation.spec.ts @@ -317,6 +317,209 @@ test("alwaysAllow list bypasses needsApproval", async (t) => { t.is(outcome.kind, "success"); }); +test("forwards the plain session context to artifact tools", async (t) => { + const toolCall: ToolCall = { + id: "call-artifact", + function: {name: "write_walkthrough", arguments: {}}, + }; + const client = makeFakeClient({ + responses: [ + { + choices: [ + { + message: { + role: "assistant", + content: "", + tool_calls: [toolCall], + }, + }, + ], + }, + {choices: [{message: {role: "assistant", content: "done"}}]}, + ], + }); + const toolManager = makeFakeToolManager({ + knownTools: new Set(["write_walkthrough"]), + }); + let receivedSessionId: string | undefined; + let receivedWorkingDirectory: string | undefined; + setToolRegistryGetter(() => ({ + write_walkthrough: (async (_args, options) => { + receivedSessionId = options?.sessionId; + receivedWorkingDirectory = options?.workingDirectory; + return "Walkthrough saved"; + }) as ToolHandler, + })); + + await runPlainConversation({ + client, + toolManager, + systemMessage: SYSTEM, + initialMessages: [USER], + developmentMode: "yolo", + nonInteractiveAlwaysAllow: [], + abortSignal: new AbortController().signal, + sessionId: "11111111-1111-4111-8111-111111111111", + workingDirectory: "/tmp/plain-artifacts", + }); + + t.is(receivedSessionId, "11111111-1111-4111-8111-111111111111"); + t.is(receivedWorkingDirectory, "/tmp/plain-artifacts"); +}); + +test("does not nudge task-only plain work for a walkthrough", async (t) => { + let callCount = 0; + let nudge = ""; + const client = { + ...makeFakeClient({responses: []}), + chat: async (messages: Message[]): Promise => { + callCount++; + if (callCount === 1) { + return { + choices: [ + { + message: { + role: "assistant", + content: "", + tool_calls: [ + { + id: "tasks", + function: { + name: "write_tasks", + arguments: {tasks: [{title: "Implement"}]}, + }, + }, + ], + }, + }, + ], + }; + } + if (callCount === 2) { + return { + choices: [ + {message: {role: "assistant", content: "Implementation complete."}}, + ], + }; + } + if (callCount === 3) { + nudge = messages.at(-1)?.content ?? ""; + return { + choices: [ + { + message: { + role: "assistant", + content: "", + tool_calls: [ + { + id: "walkthrough", + function: { + name: "write_walkthrough", + arguments: {}, + }, + }, + ], + }, + }, + ], + }; + } + return { + choices: [{message: {role: "assistant", content: "Confirmed."}}], + }; + }, + } as LLMClient; + const toolManager = makeFakeToolManager({ + knownTools: new Set(["write_tasks", "write_walkthrough"]), + }); + setToolRegistryGetter(() => ({ + write_tasks: (async () => "Tasks updated") as ToolHandler, + write_walkthrough: (async () => "Walkthrough saved") as ToolHandler, + })); + + const outcome = await runPlainConversation({ + client, + toolManager, + systemMessage: SYSTEM, + initialMessages: [USER], + developmentMode: "yolo", + nonInteractiveAlwaysAllow: [], + abortSignal: new AbortController().signal, + sessionId: "11111111-1111-4111-8111-111111111111", + }); + + t.is(outcome.kind, "success"); + t.is(callCount, 2); + t.false(nudge.includes("write_walkthrough")); +}); + +test("keeps the pre-nudge answer as plain JSON finalText", async (t) => { + let callCount = 0; + const client = { + ...makeFakeClient({responses: []}), + chat: async (_messages: Message[], _tools: unknown, callbacks: any) => { + callCount++; + if (callCount === 1) { + callbacks.onToken?.("Implementation complete."); + return { + choices: [ + {message: {role: "assistant", content: "Implementation complete."}}, + ], + }; + } + if (callCount === 2) { + return { + choices: [ + { + message: { + role: "assistant", + content: "", + tool_calls: [ + { + id: "walkthrough", + function: { + name: "write_walkthrough", + arguments: {}, + }, + }, + ], + }, + }, + ], + }; + } + callbacks.onToken?.("Confirmed."); + return { + choices: [{message: {role: "assistant", content: "Confirmed."}}], + }; + }, + } as LLMClient; + const toolManager = makeFakeToolManager({ + knownTools: new Set(["write_walkthrough"]), + }); + setToolRegistryGetter(() => ({ + write_walkthrough: (async () => "Walkthrough saved") as ToolHandler, + })); + + const outcome = await runPlainConversation({ + client, + toolManager, + systemMessage: SYSTEM, + initialMessages: [ + {role: "user", content: "Implement it."}, + ], + developmentMode: "yolo", + nonInteractiveAlwaysAllow: [], + abortSignal: new AbortController().signal, + outputFormat: "json", + sessionId: "11111111-1111-4111-8111-111111111111", + }); + + t.is(outcome.kind, "success"); + t.is(outcome.finalText, "Implementation complete."); + t.is(callCount, 3); +}); + test("unknown tool produces an error result that is fed back to the model", async (t) => { const toolCall: ToolCall = { id: "call-1", diff --git a/source/plain/conversation.ts b/source/plain/conversation.ts index e12dfae89..795544b55 100644 --- a/source/plain/conversation.ts +++ b/source/plain/conversation.ts @@ -1,3 +1,8 @@ +import { + createWalkthroughLifecycle, + observeSuccessfulLifecycleTool, + takeWalkthroughFallback, +} from '@/artifacts/walkthrough-lifecycle'; import {DEFAULT_HEADLESS_MAX_TURNS, getAppConfig} from '@/config/index'; import {processToolUse} from '@/message-handler'; import {color, write, writeError, writeLine, writeStatus} from '@/plain/writer'; @@ -33,6 +38,8 @@ export interface RunPlainConversationOptions { tune?: TuneConfig; model?: string; outputFormat?: 'text' | 'json'; + sessionId?: string; + workingDirectory?: string; } export interface PlainConversationUsage { @@ -98,12 +105,16 @@ export async function runPlainConversation( tune, model, outputFormat = 'text', + sessionId, + workingDirectory = process.cwd(), } = options; const isJson = outputFormat === 'json'; let messages = initialMessages; + const walkthroughLifecycle = createWalkthroughLifecycle(initialMessages); let accumulatedFinalText = ''; + let finalTextBeforeWalkthroughNudge: string | undefined; let accumulatedReasoning = ''; const toolCallsLog: ToolCallLog[] = []; @@ -322,9 +333,20 @@ export async function runPlainConversation( usage: getUsage(), }; } + const walkthroughFallback = finalTurn + ? null + : takeWalkthroughFallback( + walkthroughLifecycle, + availableNames.includes('write_walkthrough'), + ); + if (walkthroughFallback) { + finalTextBeforeWalkthroughNudge ??= accumulatedFinalText; + messages = [...messages, walkthroughFallback]; + continue; + } return { kind: 'success', - finalText: accumulatedFinalText, + finalText: finalTextBeforeWalkthroughNudge ?? accumulatedFinalText, reasoning: accumulatedReasoning || null, toolCalls: toolCallsLog, usage: getUsage(), @@ -365,8 +387,15 @@ export async function runPlainConversation( writeStatus(`tool: ${toolCall.function.name}`); } - const toolResult = await processToolUse(toolCall); + const toolResult = await processToolUse(toolCall, { + abortSignal, + sessionId, + workingDirectory, + }); toolResults.push(toolResult); + if (!toolResult.isError) { + observeSuccessfulLifecycleTool(walkthroughLifecycle, toolCall); + } const contentStr = toolResult.content ? typeof toolResult.content === 'string' diff --git a/source/plain/initialize.ts b/source/plain/initialize.ts index f4aa98642..623562dff 100644 --- a/source/plain/initialize.ts +++ b/source/plain/initialize.ts @@ -18,7 +18,6 @@ import {writeStatus} from '@/plain/writer'; import {SubagentExecutor} from '@/subagents/subagent-executor'; import {getSubagentLoader} from '@/subagents/subagent-loader'; import {setAgentToolExecutor, setAvailableAgentNames} from '@/tools/agent-tool'; -import {clearAllTasks} from '@/tools/tasks'; import {ToolManager} from '@/tools/tool-manager'; import type {LLMClient, MCPInitResult} from '@/types/index'; import {setAvailableSubagents} from '@/utils/prompt-processor'; @@ -45,10 +44,6 @@ export interface PlainInitOptions { export async function initializePlain( options: PlainInitOptions = {}, ): Promise { - // Fire-and-forget; must not crash the process when cwd is unwritable - // (e.g. ACP spawned by an editor with cwd=/) - clearAllTasks().catch(() => {}); - const toolManager = new ToolManager(); const customCommandLoader = new CustomCommandLoader(); const preferences = loadPreferences(); diff --git a/source/plain/shell.spec.ts b/source/plain/shell.spec.ts index f8a2269c5..81f6707c6 100644 --- a/source/plain/shell.spec.ts +++ b/source/plain/shell.spec.ts @@ -23,6 +23,8 @@ interface CapturedShutdown { function makeFakeShutdownManager(captured: CapturedShutdown) { return () => ({ + register: () => undefined, + unregister: () => undefined, gracefulShutdown: async (code: number) => { captured.code = code; }, @@ -93,11 +95,136 @@ function baseDeps( return { loadPreferences: () => ({ trustedDirectories: [] }) as never, savePreferences: () => undefined, + artifacts: { + cleanupStaleEphemeralSessions: async () => undefined, + markEphemeralSession: async () => undefined, + deleteSessionArtifacts: async () => undefined, + }, getShutdownManager: makeFakeShutdownManager({ code: null }), ...overrides, }; } +test.serial("plain shell creates a session for artifact tools", async (t) => { + const shutdown: CapturedShutdown = {code: null}; + const stdout = capturingStdout(); + let sessionId: string | undefined; + let workingDirectory: string | undefined; + try { + await runPlainShell({ + prompt: "do the thing", + developmentMode: "yolo", + trustDirectory: true, + outputFormat: "json", + deps: baseDeps({ + initializePlain: makeFakeInitializePlain(), + runPlainConversation: async options => { + sessionId = options.sessionId; + workingDirectory = options.workingDirectory; + return { + kind: "success", + finalText: "done", + reasoning: null, + toolCalls: [], + }; + }, + getShutdownManager: makeFakeShutdownManager(shutdown), + }), + }); + } finally { + stdout.restore(); + } + + t.regex(sessionId ?? "", /^[0-9a-f-]{36}$/); + t.is(workingDirectory, process.cwd()); +}); + +test.serial("plain shell marks and cleans its ephemeral artifact session", async (t) => { + const shutdown: CapturedShutdown = {code: null}; + const stdout = capturingStdout(); + const calls: string[] = []; + let conversationSessionId = ""; + try { + await runPlainShell({ + prompt: "do the thing", + developmentMode: "yolo", + trustDirectory: true, + outputFormat: "json", + deps: baseDeps({ + initializePlain: makeFakeInitializePlain(), + runPlainConversation: async options => { + conversationSessionId = options.sessionId ?? ""; + return { + kind: "success", + finalText: "done", + reasoning: null, + toolCalls: [], + }; + }, + artifacts: { + cleanupStaleEphemeralSessions: async () => { + calls.push("sweep"); + }, + markEphemeralSession: async sessionId => { + calls.push(`mark:${sessionId}`); + }, + deleteSessionArtifacts: async sessionId => { + calls.push(`delete:${sessionId}`); + }, + }, + getShutdownManager: makeFakeShutdownManager(shutdown), + }), + }); + } finally { + stdout.restore(); + } + + t.deepEqual(calls, [ + "sweep", + `mark:${conversationSessionId}`, + `delete:${conversationSessionId}`, + ]); +}); + +test.serial("plain shell cleans its ephemeral session when the conversation throws", async (t) => { + const shutdown: CapturedShutdown = {code: null}; + const stdout = capturingStdout(); + let markedSessionId = ""; + let deletedSessionId = ""; + try { + await t.throwsAsync( + runPlainShell({ + prompt: "do the thing", + developmentMode: "yolo", + trustDirectory: true, + outputFormat: "json", + deps: baseDeps({ + initializePlain: makeFakeInitializePlain(), + runPlainConversation: async () => { + throw new Error("conversation failed"); + }, + artifacts: { + cleanupStaleEphemeralSessions: async () => undefined, + markEphemeralSession: async sessionId => { + markedSessionId = sessionId; + }, + deleteSessionArtifacts: async sessionId => { + deletedSessionId = sessionId; + }, + }, + getShutdownManager: makeFakeShutdownManager(shutdown), + }), + }), + {message: "conversation failed"}, + ); + } finally { + stdout.restore(); + } + + t.regex(markedSessionId, /^[0-9a-f-]{36}$/); + t.is(deletedSessionId, markedSessionId); +}); + test.serial( "--json success outcome emits a well-formed report with exit code 0", async (t) => { diff --git a/source/plain/shell.ts b/source/plain/shell.ts index fbfd8cb10..9cd132474 100644 --- a/source/plain/shell.ts +++ b/source/plain/shell.ts @@ -1,5 +1,10 @@ +import {randomUUID} from 'node:crypto'; import path from 'node:path'; import {appendToolDefinitionsToPrompt} from '@/ai-sdk-client/tools/system-prompt-assembler'; +import { + type ArtifactManager, + artifactManager, +} from '@/artifacts/artifact-manager'; import {getAppConfig} from '@/config/index'; import {loadPreferences, savePreferences} from '@/config/preferences'; import {resolveTune} from '@/config/tune'; @@ -42,6 +47,12 @@ export interface RunPlainShellDeps { getShutdownManager: typeof getShutdownManager; loadPreferences: typeof loadPreferences; savePreferences: typeof savePreferences; + artifacts: Pick< + ArtifactManager, + | 'cleanupStaleEphemeralSessions' + | 'markEphemeralSession' + | 'deleteSessionArtifacts' + >; } const defaultDeps: RunPlainShellDeps = { @@ -50,6 +61,7 @@ const defaultDeps: RunPlainShellDeps = { getShutdownManager, loadPreferences, savePreferences, + artifacts: artifactManager, }; /** @@ -166,6 +178,31 @@ export async function runPlainShell( const abortController = new AbortController(); const sigint = () => abortController.abort(); process.on('SIGINT', sigint); + const sessionId = randomUUID(); + await deps.artifacts.cleanupStaleEphemeralSessions(); + await deps.artifacts.markEphemeralSession(sessionId); + + const shutdownManager = deps.getShutdownManager(); + const cleanupHandlerName = `plain-artifacts-${sessionId}`; + let conversationPromise: + | ReturnType + | undefined; + let cleaned = false; + const cleanupArtifacts = async () => { + if (cleaned) return; + await deps.artifacts.deleteSessionArtifacts(sessionId); + cleaned = true; + shutdownManager.unregister(cleanupHandlerName); + }; + shutdownManager.register({ + name: cleanupHandlerName, + priority: 10, + handler: async () => { + abortController.abort(); + await conversationPromise?.catch(() => undefined); + await cleanupArtifacts(); + }, + }); const nonInteractiveAlwaysAllow = getAppConfig().alwaysAllow ?? []; @@ -173,19 +210,27 @@ export async function runPlainShell( writeLine(); } - const outcome = await deps.runPlainConversation({ - client, - toolManager, - systemMessage, - initialMessages, - developmentMode, - nonInteractiveAlwaysAllow, - abortSignal: abortController.signal, - tune, - model, - outputFormat, - }); - process.off('SIGINT', sigint); + let outcome; + try { + conversationPromise = deps.runPlainConversation({ + client, + toolManager, + systemMessage, + initialMessages, + developmentMode, + nonInteractiveAlwaysAllow, + abortSignal: abortController.signal, + tune, + model, + outputFormat, + sessionId, + workingDirectory: process.cwd(), + }); + outcome = await conversationPromise; + } finally { + process.off('SIGINT', sigint); + await cleanupArtifacts(); + } if (isJson) { const exitCode = diff --git a/source/session/cli-session-context.ts b/source/session/cli-session-context.ts new file mode 100644 index 000000000..560fcf270 --- /dev/null +++ b/source/session/cli-session-context.ts @@ -0,0 +1,9 @@ +let activeSessionId: string | null = null; + +export function setCliSessionId(sessionId: string | null): void { + activeSessionId = sessionId; +} + +export function getCliSessionId(): string | null { + return activeSessionId; +} diff --git a/source/session/session-history-renderer.spec.tsx b/source/session/session-history-renderer.spec.tsx index 8df01cc0d..99b98e272 100644 --- a/source/session/session-history-renderer.spec.tsx +++ b/source/session/session-history-renderer.spec.tsx @@ -48,6 +48,20 @@ test('renders user prompts and assistant replies', t => { t.notRegex(output, /system prompt that should not appear/); }); +test('does not replay internal walkthrough fallback messages', t => { + const output = renderHistory([ + { + role: 'user', + content: + 'call write_walkthrough', + }, + {role: 'assistant', content: 'Walkthrough saved.'}, + ]); + + t.notRegex(output, /nanocoder-internal-walkthrough|call write_walkthrough/); + t.regex(output, /Walkthrough saved/); +}); + test('renders tool calls as compact summaries paired with results', t => { const messages: Message[] = [ {role: 'user', content: 'read the config'}, diff --git a/source/session/session-history-renderer.tsx b/source/session/session-history-renderer.tsx index 7708900a5..2c378e841 100644 --- a/source/session/session-history-renderer.tsx +++ b/source/session/session-history-renderer.tsx @@ -1,5 +1,6 @@ import {Box, Text} from 'ink'; import React, {memo} from 'react'; +import {isInternalWalkthroughMessage} from '@/artifacts/walkthrough-lifecycle'; import AssistantMessage from '@/components/assistant-message'; import AssistantReasoning from '@/components/assistant-reasoning'; import {InfoMessage} from '@/components/message-box'; @@ -129,20 +130,27 @@ export function buildSessionHistoryComponents( model: string, ): React.ReactNode[] { const components: React.ReactNode[] = []; + const visibleMessages = messages.filter( + message => !isInternalWalkthroughMessage(message), + ); // Map every tool result by its tool_call_id across the FULL history, so an // in-window assistant tool call can still find its result even if windowing // trims nearby messages. const resultsById = new Map(); - for (const message of messages) { + for (const message of visibleMessages) { if (message.role === 'tool' && message.tool_call_id) { resultsById.set(message.tool_call_id, message.content); } } // Replay only the trailing window; note how many earlier messages are hidden. - const hiddenCount = Math.max(0, messages.length - MAX_REPLAYED_MESSAGES); - const replayed = hiddenCount > 0 ? messages.slice(hiddenCount) : messages; + const hiddenCount = Math.max( + 0, + visibleMessages.length - MAX_REPLAYED_MESSAGES, + ); + const replayed = + hiddenCount > 0 ? visibleMessages.slice(hiddenCount) : visibleMessages; if (hiddenCount > 0) { components.push( diff --git a/source/session/session-id.ts b/source/session/session-id.ts new file mode 100644 index 000000000..fe9be930a --- /dev/null +++ b/source/session/session-id.ts @@ -0,0 +1,7 @@ +/** UUID-shaped session ID validation shared by session and artifact storage. */ +const SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isValidSessionId(id: string): boolean { + return SESSION_ID_PATTERN.test(id); +} diff --git a/source/session/session-manager.spec.ts b/source/session/session-manager.spec.ts index 3111a4f3c..438ec13e9 100644 --- a/source/session/session-manager.spec.ts +++ b/source/session/session-manager.spec.ts @@ -9,6 +9,7 @@ import { import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; +import {ArtifactManager} from '@/artifacts/artifact-manager'; import {SessionManager} from './session-manager.js'; let testDir: string; @@ -86,6 +87,22 @@ test.serial('createSession writes session file to disk', async t => { t.is(parsed.title, 'Disk test'); }); +test.serial('createSession can persist a preallocated session ID', async t => { + const id = '11111111-1111-4111-8111-111111111111'; + const session = await manager.createSession({ + id, + title: 'Preallocated session', + messageCount: 1, + provider: 'test', + model: 'test', + workingDirectory: '/tmp', + messages: [{role: 'user', content: 'hello'}], + }); + + t.is(session.id, id); + t.is((await manager.readSession(id))?.title, 'Preallocated session'); +}); + test.serial('createSession adds metadata to the index', async t => { const session = await manager.createSession({ title: 'Index test', @@ -258,6 +275,25 @@ test.serial( }, ); +test.serial('deleteSession removes the session artifacts', async t => { + const artifacts = new ArtifactManager(join(testDir, 'artifacts')); + const coupledManager = new SessionManager(sessionsDir, artifacts); + await coupledManager.initialize(); + const session = await coupledManager.createSession({ + title: 'Delete artifacts', + messageCount: 0, + provider: 'test', + model: 'test', + workingDirectory: '/tmp', + messages: [], + }); + await artifacts.writeArtifact(session.id, 'implementation_plan', '# Plan\n'); + + await coupledManager.deleteSession(session.id); + + t.is(await artifacts.readArtifact(session.id, 'implementation_plan'), null); +}); + test.serial('deleteSession rejects invalid ID', async t => { await t.throwsAsync(() => manager.deleteSession('bad-id'), { message: /Invalid session ID/, @@ -662,9 +698,15 @@ test.serial( oldDate.setDate(oldDate.getDate() - 60); index[0].lastAccessedAt = oldDate.toISOString(); await writeFile(indexPath, JSON.stringify(index), 'utf-8'); + const artifacts = new ArtifactManager(join(testDir, 'artifacts')); + await artifacts.writeArtifact( + session.id, + 'implementation_plan', + '# Old plan\n', + ); // Re-initialize to trigger cleanup (default retention is 30 days) - const mgr2 = new SessionManager(sessionsDir); + const mgr2 = new SessionManager(sessionsDir, artifacts); await mgr2.initialize(); const sessions = await mgr2.listSessions(); @@ -673,6 +715,7 @@ test.serial( // File should also be deleted const result = await mgr2.readSession(session.id); t.is(result, null); + t.is(await artifacts.readArtifact(session.id, 'implementation_plan'), null); }, ); diff --git a/source/session/session-manager.ts b/source/session/session-manager.ts index 8257c2cb9..db1593dd8 100644 --- a/source/session/session-manager.ts +++ b/source/session/session-manager.ts @@ -1,15 +1,16 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { + type ArtifactManager, + artifactManager, +} from '@/artifacts/artifact-manager'; import {getAppConfig} from '@/config/index'; import {getAppDataPath} from '@/config/paths'; import {MAX_SESSION_NAME_LENGTH} from '@/constants'; +import {isValidSessionId} from '@/session/session-id'; import type {Message} from '@/types/core'; -/** UUID v4 pattern for session ID validation (prevents path traversal) */ -const SESSION_ID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; - export interface Session { id: string; title: string; @@ -37,10 +38,6 @@ export interface SessionMetadata { titleManuallySet?: boolean; } -function isValidSessionId(id: string): boolean { - return SESSION_ID_PATTERN.test(id); -} - function isRecord(obj: unknown): obj is Record { return typeof obj === 'object' && obj !== null && !Array.isArray(obj); } @@ -98,7 +95,10 @@ export class SessionManager { /** Optional explicit directory override (used by tests). */ private readonly overrideDir?: string; - constructor(sessionsDir?: string) { + constructor( + sessionsDir?: string, + private readonly artifacts: ArtifactManager = artifactManager, + ) { this.overrideDir = sessionsDir; } @@ -164,9 +164,11 @@ export class SessionManager { } async createSession( - sessionData: Omit, + sessionData: Omit & { + id?: string; + }, ): Promise { - const sessionId = crypto.randomUUID(); + const sessionId = sessionData.id ?? crypto.randomUUID(); const timestamp = new Date().toISOString(); const session: Session = { @@ -398,6 +400,8 @@ export class SessionManager { 0o600, ); }); + + await this.artifacts.deleteSessionArtifacts(sessionId); } getSessionDirectory(): string { @@ -460,6 +464,7 @@ export class SessionManager { throw error; } } + await this.artifacts.deleteSessionArtifacts(session.id); } }); } @@ -501,6 +506,7 @@ export class SessionManager { throw error; } } + await this.artifacts.deleteSessionArtifacts(session.id); } }); } diff --git a/source/subagents/subagent-executor.spec.ts b/source/subagents/subagent-executor.spec.ts index cfb6cc6f9..6e14f1788 100644 --- a/source/subagents/subagent-executor.spec.ts +++ b/source/subagents/subagent-executor.spec.ts @@ -3,7 +3,12 @@ import {SubagentExecutor} from './subagent-executor.js'; import {getModelContextLimit} from '@/models'; import {SubagentLoader, getSubagentLoader} from './subagent-loader.js'; import type {ToolManager} from '@/tools/tool-manager'; -import type {LLMClient, LLMChatResponse, Message} from '@/types/core'; +import type { + LLMClient, + LLMChatResponse, + Message, + ToolExecutionContext, +} from '@/types/core'; import {MAX_TOOL_RESULT_CHARS} from '@/constants'; import {setGlobalToolApprovalHandler} from '@/utils/tool-approval-queue'; @@ -11,7 +16,17 @@ console.log('\nsubagent-executor.spec.ts'); // Helper to create a mock tool manager function createMockToolManager( - tools: Record Promise; readOnly: boolean; needsApproval?: boolean}> = {}, + tools: Record< + string, + { + handler: ( + args: unknown, + options?: ToolExecutionContext, + ) => Promise; + readOnly: boolean; + needsApproval?: boolean; + } + > = {}, ): ToolManager { return { getAllTools: () => { @@ -159,6 +174,47 @@ test.serial('executes tool calls and returns final response', async t => { t.is(result.output, 'Found the file with 100 lines'); }); +test.serial('forwards the parent execution context to subagent tools', async t => { + let receivedContext: ToolExecutionContext | undefined; + const toolManager = createMockToolManager({ + write_tasks: { + handler: async (_args, options) => { + receivedContext = options; + return 'Tasks updated'; + }, + readOnly: false, + }, + }); + const client = createMockClient([ + { + content: '', + tool_calls: [ + { + id: 'tasks', + function: {name: 'write_tasks', arguments: '{"tasks":[]}'}, + }, + ], + }, + {content: 'done'}, + ]); + const executor = new SubagentExecutor(toolManager, client); + + const result = await executor.execute( + {subagent_type: 'explore', description: 'Track the work'}, + undefined, + 0, + 'context-agent', + { + sessionId: '11111111-1111-4111-8111-111111111111', + workingDirectory: '/workspace', + }, + ); + + t.true(result.success); + t.is(receivedContext?.sessionId, '11111111-1111-4111-8111-111111111111'); + t.is(receivedContext?.workingDirectory, '/workspace'); +}); + test.serial('caps tool output before the next subagent model turn', async t => { const largeOutput = `HEAD\n${'middle\n'.repeat(MAX_TOOL_RESULT_CHARS)}TAIL`; const toolManager = createMockToolManager({ diff --git a/source/subagents/subagent-executor.ts b/source/subagents/subagent-executor.ts index c3e254af0..4e50e6223 100644 --- a/source/subagents/subagent-executor.ts +++ b/source/subagents/subagent-executor.ts @@ -28,6 +28,7 @@ import type { LLMClient, Message, ToolCall, + ToolExecutionContext, } from '@/types/core'; import {formatError} from '@/utils/error-formatter'; import {signalToolApproval} from '@/utils/tool-approval-queue'; @@ -115,6 +116,7 @@ export class SubagentExecutor { signal?: AbortSignal, depth = 0, agentId?: string, + executionContext?: Omit, ): Promise { const startTime = Date.now(); @@ -167,6 +169,7 @@ export class SubagentExecutor { config, signal, agentId, + executionContext, ); // Read final token count from the correct progress source @@ -379,6 +382,7 @@ export class SubagentExecutor { config: SubagentConfigWithSource, signal?: AbortSignal, agentId?: string, + executionContext?: Omit, ): Promise { let iterations = 0; let totalToolCalls = 0; @@ -535,6 +539,7 @@ export class SubagentExecutor { toolCall.id, config, signal, + executionContext, ); // Count tokens from tool results @@ -582,6 +587,7 @@ export class SubagentExecutor { toolCallId: string, config: SubagentConfigWithSource, signal?: AbortSignal, + executionContext?: Omit, ): Promise { if (signal?.aborted) { return 'Error: Execution was cancelled'; @@ -619,7 +625,10 @@ export class SubagentExecutor { try { const parsedArgs = parseToolArguments(rawArguments); - const result = await toolHandler(parsedArgs); + const result = await toolHandler(parsedArgs, { + ...executionContext, + abortSignal: signal, + }); // Subagents converse in text, so collapse structured output to its // text representation. const content = typeof result === 'string' ? result : result.llmContent; diff --git a/source/tools/agent-tool.spec.tsx b/source/tools/agent-tool.spec.tsx index 2fffa1d4e..a02e20283 100644 --- a/source/tools/agent-tool.spec.tsx +++ b/source/tools/agent-tool.spec.tsx @@ -155,6 +155,38 @@ test.serial('startAgentExecution returns promise that resolves', async t => { t.truthy(result.error); }); +test.serial('startAgentExecution forwards the parent execution context', async t => { + let receivedArgs: unknown[] = []; + setAgentToolExecutor({ + execute: async (...args: unknown[]) => { + receivedArgs = args; + return { + subagentName: 'explore', + output: 'done', + success: true, + executionTimeMs: 1, + }; + }, + } as unknown as SubagentExecutor); + const abortController = new AbortController(); + + const {promise} = startAgentExecution( + {subagent_type: 'explore', description: 'Test'}, + { + abortSignal: abortController.signal, + sessionId: '11111111-1111-4111-8111-111111111111', + workingDirectory: '/workspace', + }, + ); + await promise; + + t.is(receivedArgs[1], abortController.signal); + t.deepEqual(receivedArgs[4], { + sessionId: '11111111-1111-4111-8111-111111111111', + workingDirectory: '/workspace', + }); +}); + // ============================================================================ // ReadOnly Tests // ============================================================================ diff --git a/source/tools/agent-tool.tsx b/source/tools/agent-tool.tsx index 7395706c9..7653387f9 100644 --- a/source/tools/agent-tool.tsx +++ b/source/tools/agent-tool.tsx @@ -9,6 +9,7 @@ import {randomUUID} from 'node:crypto'; import type {SubagentExecutor} from '@/subagents/subagent-executor.js'; import {getSubagentLoader} from '@/subagents/subagent-loader.js'; +import type {ToolExecutionContext} from '@/types/core'; import {jsonSchema, tool} from '@/types/core'; import type {NanocoderToolExport} from '@/types/index'; @@ -58,7 +59,7 @@ export function setAvailableAgentNames( */ export function startAgentExecution( args: AgentToolArgs, - signal?: AbortSignal, + options?: ToolExecutionContext, ): { agentId: string; promise: Promise<{content: string; success: boolean; error?: string}>; @@ -74,6 +75,10 @@ export function startAgentExecution( const {subagent_type, description, prompt, context} = args; const executor = executorInstance; + const executionContext = { + sessionId: options?.sessionId, + workingDirectory: options?.workingDirectory, + }; // Wrap in setTimeout(0) to fully detach from the current call stack. // This ensures the caller can set up the live component and Ink can @@ -91,9 +96,10 @@ export function startAgentExecution( prompt, context, }, - signal, + options?.abortSignal, 0, agentId, + executionContext, ); resolve({ content: result.output, @@ -108,7 +114,7 @@ export function startAgentExecution( async function executeAgent( args: AgentToolArgs, - signal?: AbortSignal, + options?: ToolExecutionContext, ): Promise { if (!executorInstance) { throw new Error('Subagent executor not initialized'); @@ -136,9 +142,13 @@ async function executeAgent( prompt, context, }, - signal, + options?.abortSignal, 0, agentId, + { + sessionId: options?.sessionId, + workingDirectory: options?.workingDirectory, + }, ); if (!result.success) { @@ -177,8 +187,8 @@ const agentCoreTool = tool({ }, required: ['subagent_type', 'description'], }), - execute: async (args, {abortSignal}) => { - return await executeAgent(args, abortSignal); + execute: async (args, options) => { + return await executeAgent(args, options as ToolExecutionContext); }, }); diff --git a/source/tools/index.ts b/source/tools/index.ts index cda27b83c..87057ba78 100644 --- a/source/tools/index.ts +++ b/source/tools/index.ts @@ -15,6 +15,8 @@ import {searchFileContentsTool} from '@/tools/search-file-contents'; import {checkSkillTool} from '@/tools/skill-check'; import {writeTasksTool} from '@/tools/tasks'; import {webSearchTool} from '@/tools/web-search'; +import {writePlanTool} from '@/tools/write-plan'; +import {writeWalkthroughTool} from '@/tools/write-walkthrough'; import type {NanocoderToolExport} from '@/types/index'; // Static tools (always available) @@ -37,6 +39,10 @@ const staticTools: NanocoderToolExport[] = [ ...getFileOpTools(), // Task management tool writeTasksTool, + // Plan mode artifact tool + writePlanTool, + // Completion artifact tool + writeWalkthroughTool, // Skill authoring linter checkSkillTool, ]; diff --git a/source/tools/tasks/index.ts b/source/tools/tasks/index.ts index 657553c66..a8c20ade8 100644 --- a/source/tools/tasks/index.ts +++ b/source/tools/tasks/index.ts @@ -1,4 +1,2 @@ -export {clearAllTasks} from './storage'; - export type {Task, TaskStatus} from './types'; export {writeTasksTool} from './write-tasks'; diff --git a/source/tools/tasks/storage.spec.ts b/source/tools/tasks/storage.spec.ts index 50ea9acb1..b1d1ae96c 100644 --- a/source/tools/tasks/storage.spec.ts +++ b/source/tools/tasks/storage.spec.ts @@ -2,6 +2,8 @@ import {mkdir, readFile, rm, writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; +import {ArtifactManager} from '@/artifacts/artifact-manager'; +import {setCliSessionId} from '@/session/cli-session-context'; import type {Task} from './types.js'; // ============================================================================ @@ -100,6 +102,67 @@ test('loadTasks - returns empty array when no file exists', async t => { } }); +test('session tasks persist as isolated JSON and Markdown artifacts', async t => { + const root = join(testDir, 'session-artifacts'); + const artifacts = new ArtifactManager(root); + const firstSession = '11111111-1111-4111-8111-111111111111'; + const secondSession = '22222222-2222-4222-8222-222222222222'; + const {loadTasks, saveTasks} = await import('./storage.js'); + const firstTasks: Task[] = [ + { + id: 'task-1', + title: 'Inspect parser', + status: 'in_progress', + createdAt: '2026-08-07T00:00:00.000Z', + updatedAt: '2026-08-07T00:00:00.000Z', + }, + { + id: 'task-2', + title: 'Add tests', + description: 'Cover resume behavior', + status: 'completed', + createdAt: '2026-08-07T00:00:00.000Z', + updatedAt: '2026-08-07T00:00:00.000Z', + completedAt: '2026-08-07T00:00:00.000Z', + }, + ]; + + await saveTasks(firstTasks, firstSession, artifacts); + await saveTasks([], secondSession, artifacts); + + t.deepEqual(await loadTasks(firstSession, artifacts), firstTasks); + t.deepEqual(await loadTasks(secondSession, artifacts), []); + const markdown = await artifacts.readArtifact(firstSession, 'task'); + t.true(markdown?.includes('- [ ] **In progress:** Inspect parser')); + t.true(markdown?.includes('- [x] Add tests')); + t.true(markdown?.includes('Cover resume behavior')); +}); + +test('task commands use the active CLI session when no ID is passed', async t => { + const root = join(testDir, 'active-session-artifacts'); + const artifacts = new ArtifactManager(root); + const sessionId = '11111111-1111-4111-8111-111111111111'; + const tasks: Task[] = [ + { + id: 'task-1', + title: 'CLI task', + status: 'pending', + createdAt: '2026-08-07T00:00:00.000Z', + updatedAt: '2026-08-07T00:00:00.000Z', + }, + ]; + const {loadTasks, saveTasks} = await import('./storage.js'); + + try { + setCliSessionId(sessionId); + await saveTasks(tasks, undefined, artifacts); + t.deepEqual(await loadTasks(undefined, artifacts), tasks); + t.truthy(await artifacts.readArtifact(sessionId, 'task')); + } finally { + setCliSessionId(null); + } +}); + test('loadTasks - loads existing tasks from file', async t => { const env = await setupTestEnv('load-existing'); try { diff --git a/source/tools/tasks/storage.ts b/source/tools/tasks/storage.ts index 779f786ba..0874b442b 100644 --- a/source/tools/tasks/storage.ts +++ b/source/tools/tasks/storage.ts @@ -1,36 +1,87 @@ import {randomUUID} from 'node:crypto'; import {mkdir, readFile, writeFile} from 'node:fs/promises'; import {join} from 'node:path'; +import { + type ArtifactManager, + artifactManager, +} from '@/artifacts/artifact-manager'; +import {getCliSessionId} from '@/session/cli-session-context'; import type {Task} from './types'; const TASKS_DIR = '.nanocoder'; const TASKS_FILE = 'tasks.json'; -export function getTasksPath(): string { +export function getTasksPath( + sessionId?: string, + artifacts: ArtifactManager = artifactManager, +): string { + const resolvedSessionId = sessionId ?? getCliSessionId(); + if (resolvedSessionId) { + return artifacts.getArtifactPath(resolvedSessionId, 'tasks'); + } return join(process.cwd(), TASKS_DIR, TASKS_FILE); } -export async function loadTasks(): Promise { +export async function loadTasks( + sessionId?: string, + artifacts: ArtifactManager = artifactManager, +): Promise { + const resolvedSessionId = sessionId ?? getCliSessionId(); try { - const path = getTasksPath(); - const content = await readFile(path, 'utf-8'); + const content = resolvedSessionId + ? await artifacts.readArtifact(resolvedSessionId, 'tasks') + : await readFile(getTasksPath(), 'utf-8'); + if (!content) return []; return JSON.parse(content) as Task[]; } catch { return []; } } -export async function saveTasks(tasks: Task[]): Promise { +export async function saveTasks( + tasks: Task[], + sessionId?: string, + artifacts: ArtifactManager = artifactManager, +): Promise { + const resolvedSessionId = sessionId ?? getCliSessionId(); + if (resolvedSessionId) { + await artifacts.writeArtifact( + resolvedSessionId, + 'tasks', + JSON.stringify(tasks, null, 2), + ); + await artifacts.writeArtifact( + resolvedSessionId, + 'task', + tasksToMarkdown(tasks), + ); + return; + } + const dirPath = join(process.cwd(), TASKS_DIR); await mkdir(dirPath, {recursive: true}); const path = getTasksPath(); await writeFile(path, JSON.stringify(tasks, null, 2), 'utf-8'); } +function tasksToMarkdown(tasks: Task[]): string { + const lines = ['# Tasks', '']; + for (const task of tasks) { + const checkbox = task.status === 'completed' ? '[x]' : '[ ]'; + const prefix = task.status === 'in_progress' ? '**In progress:** ' : ''; + lines.push(`- ${checkbox} ${prefix}${task.title}`); + if (task.description) lines.push(` - ${task.description}`); + } + return `${lines.join('\n')}\n`; +} + export function generateTaskId(): string { return randomUUID().slice(0, 8); } -export async function clearAllTasks(): Promise { - await saveTasks([]); +export async function clearAllTasks( + sessionId?: string, + artifacts: ArtifactManager = artifactManager, +): Promise { + await saveTasks([], sessionId, artifacts); } diff --git a/source/tools/tasks/write-tasks.spec.ts b/source/tools/tasks/write-tasks.spec.ts new file mode 100644 index 000000000..406d75abb --- /dev/null +++ b/source/tools/tasks/write-tasks.spec.ts @@ -0,0 +1,37 @@ +import {mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'ava'; +import {ArtifactManager} from '@/artifacts/artifact-manager'; +import {createWriteTasksTool} from './write-tasks'; + +test('write_tasks persists and returns the current session task list', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-write-tasks-')); + const artifacts = new ArtifactManager(root); + const sessionId = '11111111-1111-4111-8111-111111111111'; + const writeTasks = createWriteTasksTool(artifacts); + + try { + const result = await writeTasks.tool.execute!( + { + tasks: [ + {title: 'Inspect parser', status: 'in_progress'}, + {title: 'Add tests', status: 'pending'}, + ], + }, + {toolCallId: 'tasks', messages: [], sessionId} as never, + ); + + t.is(typeof result, 'object'); + if (typeof result === 'object' && result && 'structured' in result) { + const structured = result.structured as {tasks: Array<{title: string}>}; + t.deepEqual( + structured.tasks.map(task => task.title), + ['Inspect parser', 'Add tests'], + ); + } + t.truthy(await artifacts.readArtifact(sessionId, 'task')); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); diff --git a/source/tools/tasks/write-tasks.tsx b/source/tools/tasks/write-tasks.tsx index 923782a25..4e7a68396 100644 --- a/source/tools/tasks/write-tasks.tsx +++ b/source/tools/tasks/write-tasks.tsx @@ -1,9 +1,13 @@ import React from 'react'; +import { + type ArtifactManager, + artifactManager, +} from '@/artifacts/artifact-manager'; import {TaskListDisplay} from '@/components/task-list-display'; -import type {NanocoderToolExport} from '@/types/core'; +import type {NanocoderToolExport, ToolExecutionContext} from '@/types/core'; import {jsonSchema, tool} from '@/types/core'; -import {generateTaskId, loadTasks, saveTasks} from './storage'; +import {generateTaskId, saveTasks} from './storage'; import type {Task, TaskStatus} from './types'; interface TaskInput { @@ -22,10 +26,9 @@ const STATUS_ICON: Record = { completed: '✓', }; -const executeWriteTasks = async (args: WriteTasksArgs): Promise => { +const buildTasks = (args: WriteTasksArgs): Task[] => { const now = new Date().toISOString(); - - const tasks: Task[] = args.tasks.map(input => ({ + return args.tasks.map(input => ({ id: generateTaskId(), title: input.title, description: input.description, @@ -34,11 +37,22 @@ const executeWriteTasks = async (args: WriteTasksArgs): Promise => { updatedAt: now, completedAt: input.status === 'completed' ? now : undefined, })); +}; + +const executeWriteTasks = async ( + args: WriteTasksArgs, + sessionId?: string, + artifacts: ArtifactManager = artifactManager, +) => { + const tasks = buildTasks(args); - await saveTasks(tasks); + await saveTasks(tasks, sessionId, artifacts); if (tasks.length === 0) { - return 'Task list cleared. No tasks remaining.'; + return { + llmContent: 'Task list cleared. No tasks remaining.', + structured: {tasks: []}, + }; } const counts = { @@ -51,96 +65,107 @@ const executeWriteTasks = async (args: WriteTasksArgs): Promise => { .map(t => ` ${STATUS_ICON[t.status]} ${t.title}`) .join('\n'); - return `Task list (${counts.pending} pending, ${counts.in_progress} in progress, ${counts.completed} completed):\n${list}`; + return { + llmContent: `Task list (${counts.pending} pending, ${counts.in_progress} in progress, ${counts.completed} completed):\n${list}`, + structured: {tasks: JSON.parse(JSON.stringify(tasks))}, + }; }; -const writeTasksCoreTool = tool({ - description: - 'Create and track your task list for multi-step work (3+ steps, multiple files, investigation, features, refactors). ' + - 'Pass the COMPLETE list every time — this REPLACES the entire list. ' + - 'To start a task, resend every task with that one marked in_progress. ' + - 'To finish a task, resend every task with it marked completed. ' + - 'To add work, include new tasks alongside the existing ones; to drop work, omit it. ' + - 'Keep at most one task in_progress at a time. Pass an empty array to clear the list.', - inputSchema: jsonSchema({ - type: 'object', - properties: { - tasks: { - type: 'array', - description: - 'The complete, ordered task list. Replaces any existing tasks.', - items: { - type: 'object', - properties: { - title: { - type: 'string', - description: 'Short description of the task', - }, - status: { - type: 'string', - enum: ['pending', 'in_progress', 'completed'], - description: 'Task status (defaults to pending)', - }, - description: { - type: 'string', - description: 'Optional longer detail for the task', +export function createWriteTasksTool( + artifacts: ArtifactManager, +): NanocoderToolExport { + const writeTasksCoreTool = tool({ + description: + 'Create and track your task list for multi-step work (3+ steps, multiple files, investigation, features, refactors). ' + + 'Pass the COMPLETE list every time — this REPLACES the entire list. ' + + 'To start a task, resend every task with that one marked in_progress. ' + + 'To finish a task, resend every task with it marked completed. ' + + 'To add work, include new tasks alongside the existing ones; to drop work, omit it. ' + + 'Keep at most one task in_progress at a time. Pass an empty array to clear the list.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + tasks: { + type: 'array', + description: + 'The complete, ordered task list. Replaces any existing tasks.', + items: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Short description of the task', + }, + status: { + type: 'string', + enum: ['pending', 'in_progress', 'completed'], + description: 'Task status (defaults to pending)', + }, + description: { + type: 'string', + description: 'Optional longer detail for the task', + }, }, + required: ['title'], }, - required: ['title'], }, }, + required: ['tasks'], + }), + execute: async (args, options) => { + const sessionId = (options as ToolExecutionContext | undefined) + ?.sessionId; + return await executeWriteTasks(args, sessionId, artifacts); }, - required: ['tasks'], - }), - execute: async (args, _options) => { - return await executeWriteTasks(args); - }, -}); - -const writeTasksFormatter = async ( - _args: WriteTasksArgs, - _result?: string, -): Promise => { - const tasks = await loadTasks(); - return ; -}; - -const writeTasksValidator = ( - args: WriteTasksArgs, -): Promise<{valid: true} | {valid: false; error: string}> => { - if (!Array.isArray(args.tasks)) { - return Promise.resolve({ - valid: false, - error: 'Tasks must be an array (pass an empty array to clear the list)', - }); - } - - for (let i = 0; i < args.tasks.length; i++) { - const title = args.tasks[i]?.title?.trim(); + }); + + const writeTasksFormatter = async ( + _args: WriteTasksArgs, + _result?: string, + ): Promise => { + const tasks = buildTasks(_args); + return ; + }; - if (!title) { + const writeTasksValidator = ( + args: WriteTasksArgs, + ): Promise<{valid: true} | {valid: false; error: string}> => { + if (!Array.isArray(args.tasks)) { return Promise.resolve({ valid: false, - error: `Task ${i + 1}: title cannot be empty`, + error: 'Tasks must be an array (pass an empty array to clear the list)', }); } - if (title.length > 200) { - return Promise.resolve({ - valid: false, - error: `Task ${i + 1}: title is too long (max 200 characters)`, - }); + for (let i = 0; i < args.tasks.length; i++) { + const title = args.tasks[i]?.title?.trim(); + + if (!title) { + return Promise.resolve({ + valid: false, + error: `Task ${i + 1}: title cannot be empty`, + }); + } + + if (title.length > 200) { + return Promise.resolve({ + valid: false, + error: `Task ${i + 1}: title is too long (max 200 characters)`, + }); + } } - } - return Promise.resolve({valid: true}); -}; + return Promise.resolve({valid: true}); + }; -export const writeTasksTool: NanocoderToolExport = { - name: 'write_tasks' as const, - tool: writeTasksCoreTool, - formatter: writeTasksFormatter, - validator: writeTasksValidator, - // Task bookkeeping is low risk - never gated. - approval: false, -}; + return { + name: 'write_tasks' as const, + tool: writeTasksCoreTool, + formatter: writeTasksFormatter, + validator: writeTasksValidator, + // Task bookkeeping is low risk - never gated. + approval: false, + }; +} + +export const writeTasksTool = createWriteTasksTool(artifactManager); diff --git a/source/tools/tool-manager.spec.ts b/source/tools/tool-manager.spec.ts index c1a0e981b..2e6a80c99 100644 --- a/source/tools/tool-manager.spec.ts +++ b/source/tools/tool-manager.spec.ts @@ -735,9 +735,9 @@ test('getAvailableToolNames - plan mode excludes mutation tools', t => { // in plan mode — so a newly-added mutating tool can't silently leak in. agent // and ask_user are the deliberate non-readOnly exceptions (delegation / asking // the user are themselves read-only-ish in plan). -test('plan mode hides every mutating built-in tool (except agent/ask_user)', t => { +test('plan mode hides every mutating built-in tool except its safe interaction and artifact tools', t => { const manager = new ToolManager(); - const allowedNonReadOnly = new Set(['agent', 'ask_user']); + const allowedNonReadOnly = new Set(['agent', 'ask_user', 'write_plan']); const planTools = new Set(manager.getAvailableToolNames(undefined, 'plan')); for (const name of manager.getToolNames()) { @@ -755,7 +755,14 @@ test('getAvailableToolNames - plan + minimal excludes mutation tools from minima const manager = new ToolManager(); const result = manager.getAvailableToolNames({enabled: true, toolProfile: 'minimal', aggressiveCompact: false}, 'plan'); // Plan mode excludes write_file, string_replace, execute_bash from minimal - t.deepEqual(result, ['read_file', 'find_files', 'search_file_contents', 'list_directory', 'agent']); + t.deepEqual(result, [ + 'read_file', + 'find_files', + 'search_file_contents', + 'list_directory', + 'agent', + 'write_plan', + ]); }); // ============================================================================ @@ -821,7 +828,7 @@ test('getAvailableToolNames - disabledTools layered with plan mode exclusion', t test('getAvailableToolNames - empty disabledTools is a no-op', t => { const manager = new ToolManager(); const baseline = manager.getAvailableToolNames(undefined, 'normal', []); - const expected = manager.getToolNames(); + const expected = manager.getToolNames().filter(name => name !== 'write_plan'); t.deepEqual(baseline.sort(), [...expected].sort()); }); diff --git a/source/tools/tool-manager.ts b/source/tools/tool-manager.ts index aeb3a5bbe..f8a5e11d9 100644 --- a/source/tools/tool-manager.ts +++ b/source/tools/tool-manager.ts @@ -36,9 +36,9 @@ export interface ToolVisibilityOptions { // Tools to exclude per development mode const MODE_EXCLUDED_TOOLS: Record = { - normal: [], - 'auto-accept': [], - yolo: [], + normal: ['write_plan'], + 'auto-accept': ['write_plan'], + yolo: ['write_plan'], plan: [ // No mutation tools — plan mode is read-only exploration 'write_file', @@ -48,12 +48,13 @@ const MODE_EXCLUDED_TOOLS: Record = { 'execute_bash', // No task tool — plan mode produces the plan itself 'write_tasks', + 'write_walkthrough', // No git mutation tools — keep read-only git tools 'git_add', 'git_commit', 'git_pr', // can create PRs — excluded like other git mutators ], - headless: ['ask_user', 'agent'], + headless: ['ask_user', 'agent', 'write_plan'], }; /** @@ -181,6 +182,16 @@ export class ToolManager { } } + // The plan artifact is a mode capability, not a general-purpose tool. + // Keep it available even when a slim profile filters the normal tool set. + if ( + developmentMode === 'plan' && + this.registry.hasTool('write_plan') && + !names.includes('write_plan') + ) { + names.push('write_plan'); + } + // Apply mode-based exclusions if (developmentMode) { const excluded = MODE_EXCLUDED_TOOLS[developmentMode]; diff --git a/source/tools/tool-registry.ts b/source/tools/tool-registry.ts index dc62f803e..d3f23b13f 100644 --- a/source/tools/tool-registry.ts +++ b/source/tools/tool-registry.ts @@ -3,6 +3,7 @@ import type { NanocoderToolExport, StreamingFormatter, ToolEntry, + ToolExecutionContext, ToolFormatter, ToolHandler, ToolValidator, @@ -232,13 +233,13 @@ export class ToolRegistry { const rawHandler = async ( // biome-ignore lint/suspicious/noExplicitAny: Dynamic typing required args: any, - options?: {abortSignal?: AbortSignal}, + options?: ToolExecutionContext, ) => // biome-ignore lint/suspicious/noExplicitAny: Dynamic typing required await (t.tool as any).execute(args, { toolCallId: 'manual', messages: [], - abortSignal: options?.abortSignal, + ...options, }); registry.register({ name: t.name, diff --git a/source/tools/write-plan.spec.ts b/source/tools/write-plan.spec.ts new file mode 100644 index 000000000..a134b36a1 --- /dev/null +++ b/source/tools/write-plan.spec.ts @@ -0,0 +1,79 @@ +import {mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'ava'; +import {ArtifactManager} from '@/artifacts/artifact-manager'; +import {ToolManager} from '@/tools/tool-manager'; +import {ToolRegistry} from '@/tools/tool-registry'; +import {getToolJsonSchema} from '@/utils/schema-validate'; +import {createWritePlanTool} from './write-plan'; + +test('write_plan replaces the current session plan without accepting a path', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-write-plan-')); + const manager = new ArtifactManager(root); + const sessionId = '11111111-1111-4111-8111-111111111111'; + const writePlan = createWritePlanTool(manager); + + try { + await writePlan.tool.execute!( + {content: '# Initial plan\n'}, + {toolCallId: 'first', messages: [], sessionId} as never, + ); + await writePlan.tool.execute!( + {content: '# Final plan\n'}, + {toolCallId: 'second', messages: [], sessionId} as never, + ); + + t.is( + await manager.readArtifact(sessionId, 'implementation_plan'), + '# Final plan\n', + ); + const schema = getToolJsonSchema(writePlan.tool); + t.false('path' in (schema?.properties ?? {})); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); + +test('write_plan reports a missing active session clearly', async t => { + const writePlan = createWritePlanTool(new ArtifactManager('/tmp/unused')); + + await t.throwsAsync( + () => + writePlan.tool.execute!( + {content: '# Plan\n'}, + undefined as never, + ), + {message: 'write_plan requires an active session'}, + ); +}); + +test('write_plan is exposed only in plan mode', t => { + const manager = new ToolManager(); + + t.true(manager.getAvailableToolNames(undefined, 'plan').includes('write_plan')); + for (const mode of ['normal', 'auto-accept', 'yolo', 'headless'] as const) { + t.false( + manager.getAvailableToolNames(undefined, mode).includes('write_plan'), + ); + } +}); + +test('tool registry forwards the active session to write_plan', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-write-plan-')); + const manager = new ArtifactManager(root); + const sessionId = '11111111-1111-4111-8111-111111111111'; + const registry = ToolRegistry.fromToolExports([createWritePlanTool(manager)]); + + try { + await registry + .getHandler('write_plan')?.({content: '# Registry plan\n'}, {sessionId}); + + t.is( + await manager.readArtifact(sessionId, 'implementation_plan'), + '# Registry plan\n', + ); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); diff --git a/source/tools/write-plan.ts b/source/tools/write-plan.ts new file mode 100644 index 000000000..d1445ee2e --- /dev/null +++ b/source/tools/write-plan.ts @@ -0,0 +1,56 @@ +import type {ArtifactManager} from '@/artifacts/artifact-manager'; +import {artifactManager} from '@/artifacts/artifact-manager'; +import type {NanocoderToolExport, ToolExecutionContext} from '@/types/core'; +import {jsonSchema, tool} from '@/types/core'; + +interface WritePlanArgs { + content: string; +} + +export function createWritePlanTool( + manager: ArtifactManager, +): NanocoderToolExport { + return { + name: 'write_plan', + tool: tool({ + description: + 'Persist the complete implementation plan for the current session. ' + + 'Each call replaces the previous plan. Call this before finishing a planning turn.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + content: { + type: 'string', + description: 'The complete implementation plan in Markdown', + }, + }, + required: ['content'], + }), + execute: async (args, options) => { + const sessionId = (options as ToolExecutionContext | undefined) + ?.sessionId; + if (!sessionId) { + throw new Error('write_plan requires an active session'); + } + const artifactPath = await manager.writeArtifact( + sessionId, + 'implementation_plan', + args.content, + ); + return `Plan saved to ${artifactPath}`; + }, + }), + validator: async args => { + if ( + typeof args.content !== 'string' || + args.content.trim().length === 0 + ) { + return {valid: false, error: 'Plan content cannot be empty'}; + } + return {valid: true}; + }, + approval: false, + }; +} + +export const writePlanTool = createWritePlanTool(artifactManager); diff --git a/source/tools/write-walkthrough.spec.ts b/source/tools/write-walkthrough.spec.ts new file mode 100644 index 000000000..5401748be --- /dev/null +++ b/source/tools/write-walkthrough.spec.ts @@ -0,0 +1,129 @@ +import {mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'ava'; +import {ArtifactManager} from '@/artifacts/artifact-manager'; +import {ToolManager} from '@/tools/tool-manager'; +import {createWriteWalkthroughTool} from './write-walkthrough'; + +test('write_walkthrough persists a structured session walkthrough', async t => { + const root = await mkdtemp(join(tmpdir(), 'nanocoder-walkthrough-')); + const manager = new ArtifactManager(root); + const sessionId = '11111111-1111-4111-8111-111111111111'; + const writeWalkthrough = createWriteWalkthroughTool(manager); + + try { + await writeWalkthrough.tool.execute!( + { + summary: 'Added the artifact lifecycle.', + filesChanged: [ + { + path: 'source/artifacts/artifact-manager.ts', + description: 'Persists lifecycle artifacts.', + }, + ], + tests: [ + { + command: 'pnpm run test:ava source/artifacts', + status: 'passed', + details: 'All artifact tests passed.', + }, + ], + verificationSteps: ['Open walkthrough.md from the artifact bar.'], + }, + {toolCallId: 'walkthrough', messages: [], sessionId} as never, + ); + + t.is( + await manager.readArtifact(sessionId, 'walkthrough'), + '# Walkthrough\n\n' + + '## Summary\n\nAdded the artifact lifecycle.\n\n' + + '## Files Changed\n\n' + + '- `source/artifacts/artifact-manager.ts` — Persists lifecycle artifacts.\n\n' + + '## Tests\n\n' + + '- ✅ `pnpm run test:ava source/artifacts` — All artifact tests passed.\n\n' + + '## How to Verify\n\n' + + '1. Open walkthrough.md from the artifact bar.\n', + ); + } finally { + await rm(root, {recursive: true, force: true}); + } +}); + +test('write_walkthrough requires either test results or an untested reason', async t => { + const writeWalkthrough = createWriteWalkthroughTool( + new ArtifactManager('/tmp/unused'), + ); + + t.deepEqual( + await writeWalkthrough.validator?.({ + summary: 'Implemented the feature.', + filesChanged: [], + tests: [], + verificationSteps: ['Inspect the generated artifact.'], + }), + {valid: false, error: 'Provide test results or explain why tests were not run'}, + ); +}); + +test('write_walkthrough rejects an empty summary', async t => { + const writeWalkthrough = createWriteWalkthroughTool( + new ArtifactManager('/tmp/unused'), + ); + + t.deepEqual( + await writeWalkthrough.validator?.({ + summary: ' ', + filesChanged: [], + tests: [], + untestedReason: 'No executable changes.', + verificationSteps: ['Inspect the artifact.'], + }), + {valid: false, error: 'Walkthrough summary cannot be empty'}, + ); +}); + +test('write_walkthrough reports missing required fields without throwing', async t => { + const writeWalkthrough = createWriteWalkthroughTool( + new ArtifactManager('/tmp/unused'), + ); + + t.deepEqual(await writeWalkthrough.validator?.({}), { + valid: false, + error: 'Walkthrough summary is required', + }); +}); + +test('write_walkthrough requires a verification step', async t => { + const writeWalkthrough = createWriteWalkthroughTool( + new ArtifactManager('/tmp/unused'), + ); + + t.deepEqual( + await writeWalkthrough.validator?.({ + summary: 'Implemented the feature.', + filesChanged: [], + tests: [], + untestedReason: 'No executable changes.', + verificationSteps: [], + }), + {valid: false, error: 'Provide at least one verification step'}, + ); +}); + +test('write_walkthrough is exposed only in execution modes', t => { + const manager = new ToolManager(); + + for (const mode of ['normal', 'auto-accept', 'yolo', 'headless'] as const) { + t.true( + manager + .getAvailableToolNames(undefined, mode) + .includes('write_walkthrough'), + ); + } + t.false( + manager + .getAvailableToolNames(undefined, 'plan') + .includes('write_walkthrough'), + ); +}); diff --git a/source/tools/write-walkthrough.ts b/source/tools/write-walkthrough.ts new file mode 100644 index 000000000..458765b39 --- /dev/null +++ b/source/tools/write-walkthrough.ts @@ -0,0 +1,181 @@ +import type {ArtifactManager} from '@/artifacts/artifact-manager'; +import {artifactManager} from '@/artifacts/artifact-manager'; +import type {NanocoderToolExport, ToolExecutionContext} from '@/types/core'; +import {jsonSchema, tool} from '@/types/core'; + +type TestStatus = 'passed' | 'failed'; + +interface WriteWalkthroughArgs { + summary: string; + filesChanged: Array<{path: string; description: string}>; + tests: Array<{command: string; status: TestStatus; details?: string}>; + untestedReason?: string; + verificationSteps: string[]; +} + +const TEST_ICONS: Record = { + passed: '✅', + failed: '❌', +}; + +function renderWalkthrough(args: WriteWalkthroughArgs): string { + const lines = [ + '# Walkthrough', + '', + '## Summary', + '', + args.summary.trim(), + '', + '## Files Changed', + '', + ]; + + if (args.filesChanged.length === 0) { + lines.push('No files changed.'); + } else { + for (const file of args.filesChanged) { + lines.push(`- \`${file.path}\` — ${file.description}`); + } + } + + lines.push('', '## Tests', ''); + for (const testResult of args.tests) { + const details = testResult.details ? ` — ${testResult.details}` : ''; + lines.push( + `- ${TEST_ICONS[testResult.status]} \`${testResult.command}\`${details}`, + ); + } + if (args.tests.length === 0 && args.untestedReason) { + lines.push(`Not run — ${args.untestedReason}`); + } + + lines.push('', '## How to Verify', ''); + for (const [index, step] of args.verificationSteps.entries()) { + lines.push(`${index + 1}. ${step}`); + } + + return `${lines.join('\n')}\n`; +} + +export function createWriteWalkthroughTool( + manager: ArtifactManager, +): NanocoderToolExport { + return { + name: 'write_walkthrough', + tool: tool({ + description: + 'Persist the completion walkthrough for a complex implementation. ' + + 'Include only files actually changed and tests actually run; use untestedReason when no tests ran.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + summary: {type: 'string'}, + filesChanged: { + type: 'array', + items: { + type: 'object', + properties: { + path: {type: 'string'}, + description: {type: 'string'}, + }, + required: ['path', 'description'], + }, + }, + tests: { + type: 'array', + items: { + type: 'object', + properties: { + command: {type: 'string'}, + status: {type: 'string', enum: ['passed', 'failed']}, + details: {type: 'string'}, + }, + required: ['command', 'status'], + }, + }, + untestedReason: {type: 'string'}, + verificationSteps: { + type: 'array', + items: {type: 'string'}, + }, + }, + required: ['summary', 'filesChanged', 'tests', 'verificationSteps'], + }), + execute: async (args, options) => { + const sessionId = (options as ToolExecutionContext | undefined) + ?.sessionId; + if (!sessionId) { + throw new Error('write_walkthrough requires an active session'); + } + const artifactPath = await manager.writeArtifact( + sessionId, + 'walkthrough', + renderWalkthrough(args), + ); + return `Walkthrough saved to ${artifactPath}`; + }, + }), + validator: async args => { + if (typeof args?.summary !== 'string') { + return {valid: false, error: 'Walkthrough summary is required'}; + } + if (!Array.isArray(args.filesChanged)) { + return {valid: false, error: 'Files changed must be provided'}; + } + if (!Array.isArray(args.tests)) { + return {valid: false, error: 'Test results must be provided'}; + } + if (!Array.isArray(args.verificationSteps)) { + return {valid: false, error: 'Verification steps must be provided'}; + } + if (!args.summary.trim()) { + return {valid: false, error: 'Walkthrough summary cannot be empty'}; + } + if ( + args.filesChanged.some( + (file: unknown) => + !file || + typeof file !== 'object' || + typeof (file as Record).path !== 'string' || + typeof (file as Record).description !== 'string', + ) + ) { + return { + valid: false, + error: 'Each changed file must include a path and description', + }; + } + if ( + args.tests.some((testResult: unknown) => { + if (!testResult || typeof testResult !== 'object') return true; + const candidate = testResult as Record; + return ( + typeof candidate.command !== 'string' || + (candidate.status !== 'passed' && candidate.status !== 'failed') + ); + }) + ) { + return { + valid: false, + error: 'Each test result must include a command and valid status', + }; + } + if ( + args.verificationSteps.length === 0 || + args.verificationSteps.some((step: string) => !step.trim()) + ) { + return {valid: false, error: 'Provide at least one verification step'}; + } + if (args.tests.length === 0 && !args.untestedReason?.trim()) { + return { + valid: false, + error: 'Provide test results or explain why tests were not run', + }; + } + return {valid: true}; + }, + approval: false, + }; +} + +export const writeWalkthroughTool = createWriteWalkthroughTool(artifactManager); diff --git a/source/types/core.ts b/source/types/core.ts index ab038d3f4..e7feb975d 100644 --- a/source/types/core.ts +++ b/source/types/core.ts @@ -81,10 +81,16 @@ export interface StructuredToolOutput { export type ToolExecuteResult = string | StructuredToolOutput; +export interface ToolExecutionContext { + abortSignal?: AbortSignal; + sessionId?: string; + workingDirectory?: string; +} + export type ToolHandler = ( // biome-ignore lint/suspicious/noExplicitAny: Dynamic typing required -- Tool arguments are dynamically typed input: any, - options?: {abortSignal?: AbortSignal}, + options?: ToolExecutionContext, ) => Promise; export type ToolFormatter = ( diff --git a/source/utils/prompt-builder.spec.ts b/source/utils/prompt-builder.spec.ts index d033e1718..6d1060d2e 100644 --- a/source/utils/prompt-builder.spec.ts +++ b/source/utils/prompt-builder.spec.ts @@ -94,10 +94,18 @@ test('buildSystemPrompt - auto-accept mode includes autonomous approach', t => { }); test('buildSystemPrompt - plan mode includes planning approach', t => { - const result = buildSystemPrompt('plan', undefined, ALL_TOOLS); + const result = buildSystemPrompt('plan', undefined, [...ALL_TOOLS, 'write_plan']); t.true(result.includes('PLANNING MODE')); - t.true(result.includes('Do NOT make changes')); + t.true(result.includes('Do NOT make project changes')); t.true(result.includes('structured plan')); + t.true(result.includes('write_plan')); +}); + +test('buildSystemPrompt - plan mode omits artifact instructions when write_plan is unavailable', t => { + const result = buildSystemPrompt('plan', undefined, ALL_TOOLS); + + t.true(result.includes('Do NOT make changes')); + t.false(result.includes('write_plan')); }); test('buildSystemPrompt - scheduler mode includes scheduler approach', t => { @@ -134,6 +142,16 @@ test('buildSystemPrompt - includes task management when write_tasks available', t.true(result.includes('TASK MANAGEMENT')); }); +test('buildSystemPrompt - requires a truthful walkthrough when the tool is available', t => { + const result = buildSystemPrompt('normal', undefined, [ + 'write_tasks', + 'write_walkthrough', + ]); + + t.true(result.includes('write_walkthrough')); + t.true(result.includes('Only report tests you actually ran')); +}); + test('buildSystemPrompt - excludes task management in plan mode', t => { const result = buildSystemPrompt('plan', undefined, ['write_tasks', 'read_file']); t.false(result.includes('TASK MANAGEMENT')); diff --git a/source/utils/prompt-builder.ts b/source/utils/prompt-builder.ts index 4ade9c49e..c4f1702a0 100644 --- a/source/utils/prompt-builder.ts +++ b/source/utils/prompt-builder.ts @@ -219,13 +219,14 @@ export function buildSystemPrompt( sections.push(loadSection('core-principles')); } - // Mode-specific task approach (nano variant when active) + // Mode-specific task approach (nano variant when active). Retain a read-only + // fallback for clients or tool profiles where write_plan is unavailable. + const planSuffix = + developmentMode === 'plan' && !toolSet.has('write_plan') + ? '-plan-readonly' + : `-${developmentMode}`; sections.push( - loadSection( - nano - ? `task-approach-nano-${developmentMode}` - : `task-approach-${developmentMode}`, - ), + loadSection(`task-approach${nano ? '-nano' : ''}${planSuffix}`), ); // Tool rules — XML variant when native tool calling is disabled @@ -266,6 +267,10 @@ export function buildSystemPrompt( sections.push(loadSection('task-management')); } + if (toolSet.has('write_walkthrough') && developmentMode !== 'plan') { + sections.push(loadSection('walkthrough')); + } + // Web tools — only if web_search or fetch_url are available if (toolSet.has('web_search') || toolSet.has('fetch_url')) { sections.push(loadSection('web-tools')); diff --git a/source/utils/terminal-file-link.spec.ts b/source/utils/terminal-file-link.spec.ts new file mode 100644 index 000000000..d9d04ed95 --- /dev/null +++ b/source/utils/terminal-file-link.spec.ts @@ -0,0 +1,11 @@ +import test from 'ava'; +import {createTerminalFileLink} from './terminal-file-link'; + +test('createTerminalFileLink creates an OSC 8 file URL with the supplied label', t => { + const link = createTerminalFileLink('/tmp/implementation plan.md', 'Plan'); + + t.is( + link, + '\u001B]8;;file:///tmp/implementation%20plan.md\u0007Plan\u001B]8;;\u0007', + ); +}); diff --git a/source/utils/terminal-file-link.ts b/source/utils/terminal-file-link.ts new file mode 100644 index 000000000..b3bb1f76d --- /dev/null +++ b/source/utils/terminal-file-link.ts @@ -0,0 +1,11 @@ +import {pathToFileURL} from 'node:url'; + +const OSC_8 = '\u001B]8;;'; +const OSC_TERMINATOR = '\u0007'; + +export function createTerminalFileLink( + filePath: string, + label: string, +): string { + return `${OSC_8}${pathToFileURL(filePath).href}${OSC_TERMINATOR}${label}${OSC_8}${OSC_TERMINATOR}`; +} diff --git a/source/utils/tool-validation.ts b/source/utils/tool-validation.ts index e932cff1d..3a6b75e59 100644 --- a/source/utils/tool-validation.ts +++ b/source/utils/tool-validation.ts @@ -78,7 +78,7 @@ export function withValidation( schema?: Parameters[1], ): ToolHandler { if (!validator && !schema) return handler; - return async (args: unknown) => { + return async (args: unknown, options) => { if (schema) { const typeErrors = validateArgsAgainstSchema(args, schema); if (typeErrors.length > 0) { @@ -94,6 +94,6 @@ export function withValidation( throw new ToolValidationError(result.error, result.details); } } - return handler(args); + return handler(args, options); }; } diff --git a/source/vscode/chat-panel-harness.ts b/source/vscode/chat-panel-harness.ts index b4a43989b..27131cc6e 100644 --- a/source/vscode/chat-panel-harness.ts +++ b/source/vscode/chat-panel-harness.ts @@ -7,6 +7,13 @@ import {readFileSync} from 'node:fs'; import {fileURLToPath} from 'node:url'; import {createContext, runInContext} from 'node:vm'; +const MENTION_UTILS_SOURCE = readFileSync( + fileURLToPath( + new URL('../../plugins/vscode/media/mention-utils.js', import.meta.url), + ), + 'utf8', +); + const PANEL_SOURCE = readFileSync( fileURLToPath( new URL('../../plugins/vscode/media/chat-panel.js', import.meta.url), @@ -43,9 +50,8 @@ const SHELL_IDS = [ 'send-stop-btn', ]; -// biome-ignore lint/suspicious/noExplicitAny: the panel assigns arbitrary -// properties (onclick, oninput, ...) to the nodes it builds, so the stub has to -// stay open-ended. +// The panel assigns arbitrary properties (onclick, oninput, ...) to the nodes it builds, so the stub has to stay open-ended. +// biome-ignore lint/suspicious/noExplicitAny: open-ended DOM stub export type StubElement = any; /** @@ -237,6 +243,7 @@ export function createPanel(options: {marked?: boolean} = {}) { } createContext(sandbox); + runInContext(MENTION_UTILS_SOURCE, sandbox); runInContext(PANEL_SOURCE, sandbox); const container = findById(root, 'messages-container') as StubElement;